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.
Sort the issue list by when it last moved
db4081ec934ddd9a7ce123b4d3cf6d6ea43a4537 · Keenan Nemetz · 5d ago · parent a32a859a
Sort the issue list by when it last moved An issue queue is read for what changed, not for the order things were filed. The list now shows the newest activity first and carries the columns that answer the question: labels have their own column, assignees render like authors do, and the creation time and author sit under the title. The last column is "updated": the newest commit over the issue's directory, which covers an edit, a comment, a label change, and a close or reopen. The metadata branch has no such field on purpose — the commit carries the time — so the index derives it from one `git log` per rebuild or one per refreshed range, and stores it. An incremental refresh also reads the commits' paths, so an edit reverted inside the range still counts as activity. Bumps the index schema version, which drops the derived rows and rebuilds them from the branch on the next read. Closes #192
forge/index/issues.go +46−9
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | "github.com/rodent-software/vole/core/git" | ||
| 9 | 10 | "github.com/rodent-software/vole/forge/repo" | |
| 10 | 11 | "github.com/rodent-software/vole/metadata" | |
| 11 | 12 | ) | |
| @@ -13,12 +14,22 @@ import ( | |||
| 13 | 14 | // insertIssue writes the row and filter tables for one issue. The caller decides | |
| 14 | 15 | // whether it is replacing an existing issue, so this stays one shape used by the | |
| 15 | 16 | // full rebuild and the incremental refresh alike. | |
| 16 | func insertIssue(tx execer, repoID string, iss *metadata.Issue) error { | ||
| 17 | // | ||
| 18 | // updated is when the branch last touched the issue directory. It is passed in | ||
| 19 | // rather than read from the issue, which has no such field: the commit that | ||
| 20 | // changed the file carries the time. | ||
| 21 | func insertIssue(tx execer, repoID string, iss *metadata.Issue, updated int64) error { | ||
| 22 | // An issue nobody has touched since it was filed still had one moment of | ||
| 23 | // activity. A commit dated before its own frontmatter is a fixture or a skewed | ||
| 24 | // clock, and reporting an update older than the opening would read as a bug. | ||
| 25 | if created := iss.Created.Unix(); updated < created { | ||
| 26 | updated = created | ||
| 27 | } | ||
| 17 | 28 | if _, err := tx.Exec( | |
| 18 | `INSERT INTO issues(repo_id, id, title, state, author, created, labels, assignees, comments, body) | ||
| 19 | VALUES(?,?,?,?,?,?,?,?,?,?)`, | ||
| 29 | `INSERT INTO issues(repo_id, id, title, state, author, created, updated, labels, assignees, comments, body) | ||
| 30 | VALUES(?,?,?,?,?,?,?,?,?,?,?)`, | ||
| 20 | 31 | repoID, iss.ID, iss.Title, iss.State, iss.Author, | |
| 21 | iss.Created.Unix(), strings.Join(iss.Labels, "\x1f"), strings.Join(iss.Assignees, "\x1f"), | ||
| 32 | iss.Created.Unix(), updated, strings.Join(iss.Labels, "\x1f"), strings.Join(iss.Assignees, "\x1f"), | ||
| 22 | 33 | len(iss.Comments), iss.Description, | |
| 23 | 34 | ); err != nil { | |
| 24 | 35 | return err | |
| @@ -103,7 +114,8 @@ func addNumbers(tx *sql.Tx, repoID string, additions []metadata.Entity) error { | |||
| 103 | 114 | return nil | |
| 104 | 115 | } | |
| 105 | 116 | ||
| 106 | // Issues returns a repo's indexed issues, newest first. | ||
| 117 | // unixTime turns a stored epoch second back into a time. Zero stays the zero | ||
| 118 | // time so a row the index has not filled in reads as absent rather than 1970. | ||
| 107 | 119 | func unixTime(sec int64) time.Time { | |
| 108 | 120 | if sec == 0 { | |
| 109 | 121 | return time.Time{} | |
| @@ -122,13 +134,37 @@ type Filter struct { | |||
| 122 | 134 | Limit int | |
| 123 | 135 | } | |
| 124 | 136 | ||
| 137 | // issueTouchTimes maps the paths one `git log` touched to the issues they belong | ||
| 138 | // to, keeping the newest commit time per issue. rev may be a commit range, so an | ||
| 139 | // incremental refresh only reads the commits it has not applied. | ||
| 140 | func issueTouchTimes(g *git.Repo, rev string) (map[string]int64, error) { | ||
| 141 | touched, err := g.TouchTimes(rev, metadata.IssuesDir) | ||
| 142 | if err != nil { | ||
| 143 | return nil, err | ||
| 144 | } | ||
| 145 | times := make(map[string]int64, len(touched)) | ||
| 146 | for path, when := range touched { | ||
| 147 | id, ok := metadata.IssueIDFromPath(path) | ||
| 148 | if !ok { | ||
| 149 | continue | ||
| 150 | } | ||
| 151 | if when > times[id] { | ||
| 152 | times[id] = when | ||
| 153 | } | ||
| 154 | } | ||
| 155 | return times, nil | ||
| 156 | } | ||
| 157 | |||
| 125 | 158 | // issueSelect is the row every issue list scans: the indexed issue with its | |
| 126 | 159 | // `#42` alias from the numbering table. | |
| 127 | const issueSelect = `SELECT i.id, i.title, i.state, i.author, i.created, i.labels, i.assignees, i.comments, | ||
| 160 | const issueSelect = `SELECT i.id, i.title, i.state, i.author, i.created, i.updated, i.labels, i.assignees, i.comments, | ||
| 128 | 161 | COALESCE(n.number, 0) | |
| 129 | 162 | FROM issues i | |
| 130 | 163 | LEFT JOIN entity_numbers n ON n.repo_id = i.repo_id AND n.id = i.id` | |
| 131 | 164 | ||
| 165 | // Issues returns a repo's indexed issues, most recently updated first. That is | ||
| 166 | // the order every list shows by default: an issue queue is read for what moved, | ||
| 167 | // not for what was filed last. | ||
| 132 | 168 | func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |
| 133 | 169 | q := issueSelect + ` | |
| 134 | 170 | WHERE i.repo_id=?` | |
| @@ -150,7 +186,7 @@ func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |||
| 150 | 186 | like := "%" + term + "%" | |
| 151 | 187 | args = append(args, like, like) | |
| 152 | 188 | } | |
| 153 | q += ` ORDER BY i.id DESC` | ||
| 189 | q += ` ORDER BY i.updated DESC, i.id DESC` | ||
| 154 | 190 | if f.Limit > 0 { | |
| 155 | 191 | q += ` LIMIT ?` | |
| 156 | 192 | args = append(args, f.Limit) | |
| @@ -208,12 +244,13 @@ func scanIssueRows(rows *sql.Rows) ([]IssueRow, error) { | |||
| 208 | 244 | var out []IssueRow | |
| 209 | 245 | for rows.Next() { | |
| 210 | 246 | var r IssueRow | |
| 211 | var created int64 | ||
| 247 | var created, updated int64 | ||
| 212 | 248 | var labels, assignees string | |
| 213 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &assignees, &r.Comments, &r.Number); err != nil { | ||
| 249 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &labels, &assignees, &r.Comments, &r.Number); err != nil { | ||
| 214 | 250 | return nil, err | |
| 215 | 251 | } | |
| 216 | 252 | r.Created = unixTime(created) | |
| 253 | r.Updated = unixTime(updated) | ||
| 217 | 254 | if labels != "" { | |
| 218 | 255 | r.Labels = strings.Split(labels, "\x1f") | |
| 219 | 256 | } | |