Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Assemble an issue or change from the indexed rows

9d8dd1bc7f52d0fcdfcf286734a2c7735c1d051d · Keenan Nemetz · 10d ago · parent 6b8f84c4

Assemble an issue or change from the indexed rows

The detail pages can read the same rows the lists do, as long as the
refresh runs first (it does) and the assembled record is exactly what the
branch says. Add Store.Issue and Store.Request, which put one entity back
together from its row, its comments, and — for a change — its closes and
approvals.

Two orderings had to be stored rather than inferred:

- request_closes keeps the file's position, because the branch lists the
  issues a change closes in the order the author wrote them, not in id
  order.
- comments and approvals are read in the same id/principal order git's
  path listing gives, so a rebuilt thread matches the one read from git.

The tests compare both readers field by field (reflect.DeepEqual) on an
issue with labels, assignees, a reply and a comment, and on a change with
two closes, an anchored comment and an approval. The schema version is
bumped so existing rows are rebuilt with the new column.

forge/index/detail.go +188

@@ -0,0 +1,188 @@
1 package index
2
3 // One issue or change request, assembled from the derived rows for rendering.
4 //
5 // The detail pages used to read their record from git so a body could never be
6 // stale. They can read the index for the same reason the list pages do: the
7 // handler refreshes the repository at the start of the request (Ensure), so the
8 // rows describe the branch as of that commit. A write request does not use this
9 // path — it may have just changed the record — and neither does a server without
10 // an index; both keep reading git through metadata.
11
12 import (
13 "database/sql"
14 "errors"
15 "strings"
16
17 "github.com/rodent-software/vole/metadata"
18 )
19
20 // Issue returns one issue with its comments. The comments are in creation order,
21 // which is the order the thread renders.
22 func (s *Store) Issue(repoID, id string) (*metadata.Issue, error) {
23 var (
24 iss metadata.Issue
25 created int64
26 labels string
27 assignees string
28 )
29 err := s.db.QueryRow(
30 `SELECT id, title, state, author, created, labels, assignees, body
31 FROM issues WHERE repo_id=? AND id=?`, repoID, id).
32 Scan(&iss.ID, &iss.Title, &iss.State, &iss.Author, &created, &labels, &assignees, &iss.Description)
33 if errors.Is(err, sql.ErrNoRows) {
34 return nil, ErrNotFound
35 }
36 if err != nil {
37 return nil, err
38 }
39 iss.Created = unixTime(created)
40 iss.Labels = splitList(labels)
41 iss.Assignees = splitList(assignees)
42 iss.Path = metadata.IssuesDir + "/" + id
43 comments, err := s.Comments(repoID, commentKindIssue, id)
44 if err != nil {
45 return nil, err
46 }
47 iss.Comments = commentList(comments)
48 return &iss, nil
49 }
50
51 // Request returns one change request with its closes, approvals and comments.
52 func (s *Store) Request(repoID, id string) (*metadata.Request, error) {
53 var (
54 req metadata.Request
55 created int64
56 mergedAt int64
57 )
58 err := s.db.QueryRow(
59 `SELECT id, title, state, author, created, base, head, head_ref,
60 merged_base, merged_head, merged_by, merged_at, body
61 FROM requests WHERE repo_id=? AND id=?`, repoID, id).
62 Scan(&req.ID, &req.Title, &req.State, &req.Author, &created, &req.Base, &req.Head,
63 &req.HeadRef, &req.MergedBase, &req.MergedHead, &req.MergedBy, &mergedAt, &req.Description)
64 if errors.Is(err, sql.ErrNoRows) {
65 return nil, ErrNotFound
66 }
67 if err != nil {
68 return nil, err
69 }
70 req.Created = unixTime(created)
71 req.MergedAt = unixTime(mergedAt)
72
73 rows, err := s.db.Query(
74 `SELECT issue_id FROM request_closes WHERE repo_id=? AND request_id=? ORDER BY position, issue_id`,
75 repoID, id)
76 if err != nil {
77 return nil, err
78 }
79 for rows.Next() {
80 var issueID string
81 if err := rows.Scan(&issueID); err != nil {
82 rows.Close()
83 return nil, err
84 }
85 req.Closes = append(req.Closes, issueID)
86 }
87 if err := rows.Err(); err != nil {
88 rows.Close()
89 return nil, err
90 }
91 rows.Close()
92
93 rows, err = s.db.Query(
94 `SELECT principal, head, base, created FROM request_approvals
95 WHERE repo_id=? AND request_id=? ORDER BY principal`, repoID, id)
96 if err != nil {
97 return nil, err
98 }
99 for rows.Next() {
100 var (
101 a metadata.Approval
102 created int64
103 )
104 if err := rows.Scan(&a.Principal, &a.Head, &a.Base, &created); err != nil {
105 rows.Close()
106 return nil, err
107 }
108 a.Created = unixTime(created)
109 req.Approvals = append(req.Approvals, a)
110 }
111 if err := rows.Err(); err != nil {
112 rows.Close()
113 return nil, err
114 }
115 rows.Close()
116
117 comments, err := s.Comments(repoID, commentKindRequest, id)
118 if err != nil {
119 return nil, err
120 }
121 req.Comments = commentList(comments)
122 return &req, nil
123 }
124
125 // CommentsForRepo returns every comment of one kind in a repository, for a feed
126 // that groups them by parent. It is the "all" form of Comments.
127 func (s *Store) CommentsForRepo(repoID, kind string) ([]CommentRow, error) {
128 rows, err := s.db.Query(
129 `SELECT kind, parent, id, author, created, body, reply_to, deleted, path, side, line, blob
130 FROM comments WHERE repo_id=? AND kind=? ORDER BY parent, id`,
131 repoID, kind)
132 if err != nil {
133 return nil, err
134 }
135 defer rows.Close()
136 return scanComments(rows)
137 }
138
139 // AllApprovals returns every approval in a repository, keyed by request id. The
140 // activity feed needs all of them; a list page asks for the rows it shows instead.
141 func (s *Store) AllApprovals(repoID string) (map[string][]ApprovalRow, error) {
142 rows, err := s.db.Query(
143 `SELECT request_id, principal, head, base, created FROM request_approvals
144 WHERE repo_id=? ORDER BY request_id, principal`, repoID)
145 if err != nil {
146 return nil, err
147 }
148 defer rows.Close()
149 out := map[string][]ApprovalRow{}
150 for rows.Next() {
151 var id string
152 var a ApprovalRow
153 var created int64
154 if err := rows.Scan(&id, &a.Principal, &a.Head, &a.Base, &created); err != nil {
155 return nil, err
156 }
157 a.Created = unixTime(created)
158 out[id] = append(out[id], a)
159 }
160 return out, rows.Err()
161 }
162
163 // splitList decodes the \x1f-separated label and assignee columns.
164 func splitList(s string) []string {
165 if s == "" {
166 return nil
167 }
168 return strings.Split(s, "\x1f")
169 }
170
171 // commentList turns indexed rows back into the comment shape a page renders.
172 func commentList(rows []CommentRow) []metadata.Comment {
173 if len(rows) == 0 {
174 return nil
175 }
176 out := make([]metadata.Comment, 0, len(rows))
177 for _, c := range rows {
178 comment := metadata.Comment{
179 ID: c.ID, Author: c.Author, Created: c.Created, Body: c.Body,
180 ReplyTo: c.ReplyTo, Deleted: c.Deleted,
181 }
182 if c.Path != "" {
183 comment.Anchor = &metadata.Anchor{Path: c.Path, Side: c.Side, Line: c.Line, Blob: c.Blob}
184 }
185 out = append(out, comment)
186 }
187 return out
188 }