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