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