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.
Say what each change is waiting for in the list
7cc36ba0f4ba96d3f4938b13758b2cc20c22375a · Keenan Nemetz · 5d ago · parent 946d2fd4
Say what each change is waiting for in the list A change spends most of its life in states "open" cannot describe: held for a maintainer to allow CI, running its checks, failed, waiting for a reviewer, conflicting with its base. The list now says which of those it is, in one column, with a tooltip that explains the label. The answer comes from facts a list can afford: the approvals it already counts, one batched run-queue query for every head on the page, the contributor allowance (one ref listing, not one per row), and — only for a change that would otherwise read as ready — whether it still merges into its base. The full landing plan stays on the detail page. The rest of the row follows the issue list: the author and opening moved under the title, the comment count sits at the end of that line, and the last column is "Updated", the newest commit over the request's directory. The list sorts by it. The separate Author, Approvals and Age columns are gone. Closes #194
forge/index/requests.go +51−16
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "strings" | |
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | "github.com/rodent-software/vole/core/git" | ||
| 18 | 19 | "github.com/rodent-software/vole/forge/repo" | |
| 19 | 20 | "github.com/rodent-software/vole/metadata" | |
| 20 | 21 | ) | |
| @@ -22,11 +23,15 @@ import ( | |||
| 22 | 23 | // RequestRow is one indexed request. Comments is a count: the rows themselves | |
| 23 | 24 | // are in the comments table. | |
| 24 | 25 | type RequestRow struct { | |
| 25 | ID string | ||
| 26 | Title string | ||
| 27 | State string | ||
| 28 | Author string | ||
| 29 | Created time.Time | ||
| 26 | ID string | ||
| 27 | Title string | ||
| 28 | State string | ||
| 29 | Author string | ||
| 30 | Created time.Time | ||
| 31 | // Updated is when the metadata branch last touched the request: an edit of | ||
| 32 | // its body or target, a comment, or an approval. The branch keeps no such | ||
| 33 | // field, so the index dates it from the commits, exactly like an issue. | ||
| 34 | Updated time.Time | ||
| 30 | 35 | Base string | |
| 31 | 36 | Head string | |
| 32 | 37 | HeadRef string | |
| @@ -46,7 +51,7 @@ type RequestFilter struct { | |||
| 46 | 51 | Limit int | |
| 47 | 52 | } | |
| 48 | 53 | ||
| 49 | const requestSelect = `SELECT r.id, r.title, r.state, r.author, r.created, r.base, r.head, r.head_ref, | ||
| 54 | const requestSelect = `SELECT r.id, r.title, r.state, r.author, r.created, r.updated, r.base, r.head, r.head_ref, | ||
| 50 | 55 | r.comments, COALESCE(n.number, 0) | |
| 51 | 56 | FROM requests r | |
| 52 | 57 | LEFT JOIN entity_numbers n ON n.repo_id = r.repo_id AND n.id = r.id` | |
| @@ -85,9 +90,10 @@ func (s *Store) RequestsFollowing(m *repo.Meta, branch string) (follows, ok bool | |||
| 85 | 90 | return true, true, nil | |
| 86 | 91 | } | |
| 87 | 92 | ||
| 88 | // Requests returns a repo's indexed requests, open first and newest first within | ||
| 89 | // each state. That is the order the list page shows, so paging does not have to | ||
| 90 | // re-sort what the database already ordered. | ||
| 93 | // Requests returns a repo's indexed requests, most recently updated first within | ||
| 94 | // each state, with open ones ahead of merged and closed. That is the order the | ||
| 95 | // list page shows, so paging does not have to re-sort what the database already | ||
| 96 | // ordered. | ||
| 91 | 97 | func (s *Store) Requests(repoID string, f RequestFilter) ([]RequestRow, error) { | |
| 92 | 98 | q := requestSelect + ` | |
| 93 | 99 | WHERE r.repo_id=?` | |
| @@ -101,7 +107,7 @@ func (s *Store) Requests(repoID string, f RequestFilter) ([]RequestRow, error) { | |||
| 101 | 107 | q += ` AND (r.title LIKE ? OR r.body LIKE ? OR r.author LIKE ?)` | |
| 102 | 108 | args = append(args, like, like, like) | |
| 103 | 109 | } | |
| 104 | q += ` ORDER BY CASE WHEN r.state='open' THEN 0 ELSE 1 END, r.id DESC` | ||
| 110 | q += ` ORDER BY CASE WHEN r.state='open' THEN 0 ELSE 1 END, r.updated DESC, r.id DESC` | ||
| 105 | 111 | if f.Limit > 0 { | |
| 106 | 112 | q += ` LIMIT ?` | |
| 107 | 113 | args = append(args, f.Limit) | |
| @@ -115,17 +121,39 @@ func (s *Store) Requests(repoID string, f RequestFilter) ([]RequestRow, error) { | |||
| 115 | 121 | var out []RequestRow | |
| 116 | 122 | for rows.Next() { | |
| 117 | 123 | var r RequestRow | |
| 118 | var created int64 | ||
| 119 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &r.Base, | ||
| 124 | var created, updated int64 | ||
| 125 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &r.Base, | ||
| 120 | 126 | &r.Head, &r.HeadRef, &r.CommentCount, &r.Number); err != nil { | |
| 121 | 127 | return nil, err | |
| 122 | 128 | } | |
| 123 | 129 | r.Created = unixTime(created) | |
| 130 | r.Updated = unixTime(updated) | ||
| 124 | 131 | out = append(out, r) | |
| 125 | 132 | } | |
| 126 | 133 | return out, rows.Err() | |
| 127 | 134 | } | |
| 128 | 135 | ||
| 136 | // requestTouchTimes maps the paths one `git log` touched to the requests they | ||
| 137 | // belong to, keeping the newest commit time per request. rev may be a commit | ||
| 138 | // range, so an incremental refresh only reads the commits it has not applied. | ||
| 139 | func requestTouchTimes(g *git.Repo, rev string) (map[string]int64, error) { | ||
| 140 | touched, err := g.TouchTimes(rev, metadata.RequestsDir) | ||
| 141 | if err != nil { | ||
| 142 | return nil, err | ||
| 143 | } | ||
| 144 | times := make(map[string]int64, len(touched)) | ||
| 145 | for path, when := range touched { | ||
| 146 | id, ok := metadata.RequestIDFromPath(path) | ||
| 147 | if !ok { | ||
| 148 | continue | ||
| 149 | } | ||
| 150 | if when > times[id] { | ||
| 151 | times[id] = when | ||
| 152 | } | ||
| 153 | } | ||
| 154 | return times, nil | ||
| 155 | } | ||
| 156 | |||
| 129 | 157 | // RequestCount returns the requests in one state. An empty state, or "all", | |
| 130 | 158 | // counts every request. The repository header uses it for the open badge, which | |
| 131 | 159 | // every page shows. | |
| @@ -239,12 +267,19 @@ func (s *Store) Closes(repoID, issueID string) ([]string, error) { | |||
| 239 | 267 | // insertRequest writes the row and its sets. The caller decides whether it | |
| 240 | 268 | // replaces an existing request, so this stays one shape for the rebuild and the | |
| 241 | 269 | // incremental refresh alike. | |
| 242 | func insertRequest(tx execer, repoID string, req *metadata.Request) error { | ||
| 270 | // | ||
| 271 | // updated is when the branch last touched the request directory, passed in | ||
| 272 | // because the request has no such field: the commit that changed it carries the | ||
| 273 | // time. | ||
| 274 | func insertRequest(tx execer, repoID string, req *metadata.Request, updated int64) error { | ||
| 275 | if created := unixOrZero(req.Created); updated < created { | ||
| 276 | updated = created | ||
| 277 | } | ||
| 243 | 278 | if _, err := tx.Exec( | |
| 244 | `INSERT INTO requests(repo_id, id, title, state, author, created, base, head, head_ref, | ||
| 279 | `INSERT INTO requests(repo_id, id, title, state, author, created, updated, base, head, head_ref, | ||
| 245 | 280 | merged_base, merged_head, merged_by, merged_at, body, comments) | |
| 246 | VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, | ||
| 247 | repoID, req.ID, req.Title, req.State, req.Author, unixOrZero(req.Created), | ||
| 281 | VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, | ||
| 282 | repoID, req.ID, req.Title, req.State, req.Author, unixOrZero(req.Created), updated, | ||
| 248 | 283 | req.Base, req.Head, req.HeadRef, req.MergedBase, req.MergedHead, req.MergedBy, | |
| 249 | 284 | unixOrZero(req.MergedAt), req.Description, len(req.Comments), | |
| 250 | 285 | ); err != nil { | |