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 | } | ||