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/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/incremental_test.go +102

@@ -301,3 +301,105 @@ func TestEnsureDerivesNewNumbersFromTheRangeOnly(t *testing.T) {
301 301 t.Fatal("no numbering git call was traced; the test did not observe the refresh")
302 302 }
303 303 }
304
305 // TestEnsureDatesAnIssueFromItsNewestChange pins what the issues list sorts by.
306 // The metadata branch has no updated field, so the date comes from the commits:
307 // a comment, an edit, or an edit reverted inside the refreshed range all move it,
308 // and a full rebuild derives the same value as the incremental refresh.
309 func TestEnsureDatesAnIssueFromItsNewestChange(t *testing.T) {
310 s, m, g := incrementalRepo(t)
311 base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
312 // The commit dates are the fixture; the branch's own commits would otherwise
313 // all land in the same second and the test could not tell them apart.
314 at := func(offset time.Duration) {
315 t.Helper()
316 date := fmt.Sprintf("%d +0000", base.Add(offset).Unix())
317 t.Setenv("GIT_AUTHOR_DATE", date)
318 t.Setenv("GIT_COMMITTER_DATE", date)
319 }
320 rowOf := func(t *testing.T, id string) IssueRow {
321 t.Helper()
322 rows, err := s.Issues(m.ID, Filter{})
323 if err != nil {
324 t.Fatal(err)
325 }
326 for _, row := range rows {
327 if row.ID == id {
328 return row
329 }
330 }
331 t.Fatalf("no indexed row for %s", id)
332 return IssueRow{}
333 }
334
335 at(time.Hour)
336 first := createIndexedIssue(t, g, "First", base.Add(time.Hour), nil, nil)
337 at(2 * time.Hour)
338 second := createIndexedIssue(t, g, "Second", base.Add(2*time.Hour), nil, nil)
339 if err := s.Ensure(m); err != nil {
340 t.Fatal(err)
341 }
342 if got := rowOf(t, first.ID).Updated; !got.Equal(base.Add(time.Hour)) {
343 t.Fatalf("a fresh issue updated = %v, want its opening commit %v", got, base.Add(time.Hour))
344 }
345
346 // A comment is activity: the issue it is on becomes the most recently updated
347 // one, even though the other issue was filed later.
348 at(3 * time.Hour)
349 comment, err := metadata.NewIssueComment("A comment", "keenan", base.Add(3*time.Hour))
350 if err != nil {
351 t.Fatal(err)
352 }
353 if err := metadata.AddIssueComment(g, first.ID, comment, "keenan"); err != nil {
354 t.Fatal(err)
355 }
356 if err := s.Ensure(m); err != nil {
357 t.Fatal(err)
358 }
359 if got := rowOf(t, first.ID).Updated; !got.Equal(base.Add(3 * time.Hour)) {
360 t.Fatalf("updated after a comment = %v, want %v", got, base.Add(3*time.Hour))
361 }
362 if got := rowOf(t, second.ID).Updated; !got.Equal(base.Add(2 * time.Hour)) {
363 t.Fatalf("the untouched issue updated = %v, want %v", got, base.Add(2*time.Hour))
364 }
365 rows, err := s.Issues(m.ID, Filter{})
366 if err != nil {
367 t.Fatal(err)
368 }
369 if len(rows) != 2 || rows[0].ID != first.ID {
370 t.Fatalf("default order = %+v, want the commented issue first", rows)
371 }
372
373 // Two edits that cancel out leave the tree where it started. The tree diff
374 // sees nothing; the commits still happened, and the list has to say so.
375 at(4 * time.Hour)
376 if _, err := metadata.UpdateIssue(g, first.ID, func(iss *metadata.Issue) error {
377 iss.Labels = []string{"cli"}
378 return nil
379 }, "keenan"); err != nil {
380 t.Fatal(err)
381 }
382 at(5 * time.Hour)
383 if _, err := metadata.UpdateIssue(g, first.ID, func(iss *metadata.Issue) error {
384 iss.Labels = nil
385 return nil
386 }, "keenan"); err != nil {
387 t.Fatal(err)
388 }
389 if err := s.Ensure(m); err != nil {
390 t.Fatal(err)
391 }
392 incremental := rowOf(t, first.ID).Updated
393 if !incremental.Equal(base.Add(5 * time.Hour)) {
394 t.Fatalf("updated after an edit and its revert = %v, want %v", incremental, base.Add(5*time.Hour))
395 }
396
397 // A rebuild walks the whole branch. It must land on the same date, or the two
398 // ways of filling the index would disagree about the same repository.
399 if err := s.IndexRepo(m); err != nil {
400 t.Fatal(err)
401 }
402 if rebuilt := rowOf(t, first.ID).Updated; !rebuilt.Equal(incremental) {
403 t.Fatalf("rebuilt updated = %v, incremental = %v", rebuilt, incremental)
404 }
405 }