vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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 · 4d 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
server/issues_changes_test.go +62
| @@ -9,10 +9,12 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "net/http" | |
| 11 | 11 | "net/url" | |
| 12 | "os" | ||
| 12 | 13 | "path/filepath" | |
| 13 | 14 | "strconv" | |
| 14 | 15 | "strings" | |
| 15 | 16 | "testing" | |
| 17 | "time" | ||
| 16 | 18 | ||
| 17 | 19 | "github.com/rodent-software/vole/forge/index" | |
| 18 | 20 | "github.com/rodent-software/vole/metadata" | |
| @@ -1417,3 +1419,63 @@ func TestRepoTabCountsPollOutOfBand(t *testing.T) { | |||
| 1417 | 1419 | t.Fatalf("counts for an unknown repository = %d, want 404", resp.StatusCode) | |
| 1418 | 1420 | } | |
| 1419 | 1421 | } | |
| 1422 | |||
| 1423 | // TestIssueListShowsActivityColumnsAndSortsByUpdated: the list is read for what | ||
| 1424 | // moved, so the row carries the opening under the title, labels in their own | ||
| 1425 | // column, people as people, and the time of the last edit or comment. | ||
| 1426 | func TestIssueListShowsActivityColumnsAndSortsByUpdated(t *testing.T) { | ||
| 1427 | t.Parallel() | ||
| 1428 | f := newFixture(t) | ||
| 1429 | f.publish(f.repo) | ||
| 1430 | f.addWriter(t) // wilbur, so an assignee is a real profile link | ||
| 1431 | const olderID = "01M5JR2JK29KKMAA1T9MDNTPC1" | ||
| 1432 | const newerID = "01M5JR2JK29KKMAA1T9MDNTPC2" | ||
| 1433 | files := map[string]string{ | ||
| 1434 | "issues/" + olderID + "/issue.md": issueFile("Older issue", "keenan", "open", "2026-10-20T10:00:00Z", []string{"bug"}, "body\n", "wilbur"), | ||
| 1435 | "issues/" + newerID + "/issue.md": issueFile("Newer issue", "keenan", "open", "2026-10-22T10:00:00Z", nil, "body\n"), | ||
| 1436 | } | ||
| 1437 | work := f.issuesRepo(t, files) | ||
| 1438 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 1439 | t.Fatalf("push issues: %v\n%s", err, errs) | ||
| 1440 | } | ||
| 1441 | |||
| 1442 | // Edit the older issue in a commit of its own, dated after the first push. | ||
| 1443 | // Creation order and the ULID both put the newer issue first, so only sorting | ||
| 1444 | // by activity can move the edited one to the top. | ||
| 1445 | edited := issueFile("Older issue, edited", "keenan", "open", "2026-10-20T10:00:00Z", []string{"bug"}, "body\n", "wilbur") | ||
| 1446 | if err := os.WriteFile(filepath.Join(work, "issues", olderID, "issue.md"), []byte(edited), 0o644); err != nil { | ||
| 1447 | t.Fatal(err) | ||
| 1448 | } | ||
| 1449 | if _, errs, err := runGit(t, work, "add", "-A"); err != nil { | ||
| 1450 | t.Fatalf("add the edit: %v: %s", err, errs) | ||
| 1451 | } | ||
| 1452 | later := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) | ||
| 1453 | if _, errs, err := f.git(work, "commit", "-qm", "edit the older issue", "--date", later); err != nil { | ||
| 1454 | t.Fatalf("commit the edit: %v\n%s", err, errs) | ||
| 1455 | } | ||
| 1456 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 1457 | t.Fatalf("push the edit: %v\n%s", err, errs) | ||
| 1458 | } | ||
| 1459 | |||
| 1460 | code, body := f.get("/r/" + f.repo + "/issues") | ||
| 1461 | if code != http.StatusOK { | ||
| 1462 | t.Fatalf("issue list: %d\n%s", code, body) | ||
| 1463 | } | ||
| 1464 | for _, want := range []string{ | ||
| 1465 | "<th>Status</th><th>Issue</th><th>Labels</th><th>Assignees</th><th>Replies</th><th>Updated</th>", | ||
| 1466 | `<span class="label">bug</span>`, | ||
| 1467 | `opened by <a class="user" href="/u/keenan">keenan</a>`, | ||
| 1468 | `<a class="user" href="/u/wilbur">wilbur</a>`, | ||
| 1469 | } { | ||
| 1470 | if !strings.Contains(body, want) { | ||
| 1471 | t.Fatalf("issue list is missing %q:\n%s", want, body) | ||
| 1472 | } | ||
| 1473 | } | ||
| 1474 | if strings.Contains(body, "<th>Age</th>") || strings.Contains(body, "<th>Author</th>") { | ||
| 1475 | t.Fatalf("issue list still has the replaced columns:\n%s", body) | ||
| 1476 | } | ||
| 1477 | editedAt, otherAt := strings.Index(body, "Older issue, edited"), strings.Index(body, "Newer issue") | ||
| 1478 | if editedAt < 0 || otherAt < 0 || editedAt > otherAt { | ||
| 1479 | t.Fatalf("issue list is not ordered by updated: edited at %d, other at %d\n%s", editedAt, otherAt, body) | ||
| 1480 | } | ||
| 1481 | } | ||