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.
Show real accounts as repository contributors
a32a859a08276681497be390d6f7195098a97ab4 · Keenan Nemetz · 5d ago · parent 802e0e52
Show real accounts as repository contributors Count contributors from the change and issue records instead of the author text in commits: changes merged, issues those changes fixed, issues the reporter opened that were fixed, and reviews that named the merged head. The overview lists accounts by that work with compact icon counts and full labels in tooltips. The shortlog-derived contributor table and its refresh path are gone.
forge/index/contributors_test.go +158
| @@ -0,0 +1,158 @@ | |||
| 1 | package index | ||
| 2 | |||
| 3 | // A contributor count has to be the same story the metadata branch tells. These | ||
| 4 | // tests hold the four rules apart: a merged change credits its author, the issues | ||
| 5 | // it closed credit both the fixer and the reporter, a stale approval is not a | ||
| 6 | // review, and an unlanded promise to close an issue counts for nothing. | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/rodent-software/vole/core/git" | ||
| 14 | "github.com/rodent-software/vole/metadata" | ||
| 15 | ) | ||
| 16 | |||
| 17 | func TestContributorSummariesCountsTheFourKindsOfWork(t *testing.T) { | ||
| 18 | t.Parallel() | ||
| 19 | s, m, g := incrementalRepo(t) | ||
| 20 | now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) | ||
| 21 | |||
| 22 | newIssue := func(title, author string) *metadata.Issue { | ||
| 23 | t.Helper() | ||
| 24 | iss, err := metadata.NewIssue(title, "body", author, nil, nil, now) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | if err := metadata.CreateIssue(g, iss, author); err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | now = now.Add(time.Minute) | ||
| 32 | return iss | ||
| 33 | } | ||
| 34 | reportedByBob := newIssue("bob's report", "bob") | ||
| 35 | reportedByAlice := newIssue("alice's own report", "alice") | ||
| 36 | reportedByDave := newIssue("dave's report", "dave") | ||
| 37 | // Closing an issue by hand is not a landing, so it proves no fix. | ||
| 38 | if _, err := metadata.SetIssueState(g, reportedByDave.ID, "closed", "dave"); err != nil { | ||
| 39 | t.Fatal(err) | ||
| 40 | } | ||
| 41 | |||
| 42 | head := requestHead | ||
| 43 | moved := strings.Repeat("b", 40) | ||
| 44 | |||
| 45 | // Alice fixes both reports. The request closes the issue Dave opened in a | ||
| 46 | // different request, which never lands. | ||
| 47 | req, err := metadata.NewRequest("fix two reports", "why", "alice", "main", head, now) | ||
| 48 | if err != nil { | ||
| 49 | t.Fatal(err) | ||
| 50 | } | ||
| 51 | req.Closes = []string{reportedByBob.ID, reportedByAlice.ID} | ||
| 52 | if err := metadata.CreateRequest(g, req, "alice"); err != nil { | ||
| 53 | t.Fatal(err) | ||
| 54 | } | ||
| 55 | // Dave reviewed the first head. Alice pushes a new one afterwards, so his | ||
| 56 | // approval no longer names what merges. | ||
| 57 | if _, err := metadata.ApproveRequest(g, req.ID, "dave", "main", head, now, "dave"); err != nil { | ||
| 58 | t.Fatal(err) | ||
| 59 | } | ||
| 60 | if _, err := metadata.SetRequestHead(g, req.ID, moved, "", "alice"); err != nil { | ||
| 61 | t.Fatal(err) | ||
| 62 | } | ||
| 63 | if _, err := metadata.ApproveRequest(g, req.ID, "carol", "main", moved, now, "carol"); err != nil { | ||
| 64 | t.Fatal(err) | ||
| 65 | } | ||
| 66 | if _, _, err := metadata.MarkMerged(g, req.ID, head, moved, moved, "carol", now); err != nil { | ||
| 67 | t.Fatal(err) | ||
| 68 | } | ||
| 69 | |||
| 70 | // Dave promises to close his own report, but the request stays open. | ||
| 71 | open, err := metadata.NewRequest("dave's fix", "why", "dave", "main", head, now) | ||
| 72 | if err != nil { | ||
| 73 | t.Fatal(err) | ||
| 74 | } | ||
| 75 | open.Closes = []string{reportedByDave.ID} | ||
| 76 | if err := metadata.CreateRequest(g, open, "dave"); err != nil { | ||
| 77 | t.Fatal(err) | ||
| 78 | } | ||
| 79 | |||
| 80 | if err := s.Ensure(m); err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | sums, err := s.ContributorSummaries(m.ID) | ||
| 84 | if err != nil { | ||
| 85 | t.Fatal(err) | ||
| 86 | } | ||
| 87 | |||
| 88 | alice := sums["alice"] | ||
| 89 | if alice.Merged != 1 || alice.Fixed != 2 || alice.Reported != 1 || alice.Reviews != 0 { | ||
| 90 | t.Fatalf("alice = %+v, want 1 merged, 2 fixed, 1 reported", alice) | ||
| 91 | } | ||
| 92 | if alice.Total() != 4 { | ||
| 93 | t.Fatalf("alice total = %d, want 4", alice.Total()) | ||
| 94 | } | ||
| 95 | if bob := sums["bob"]; bob.Reported != 1 || bob.Merged != 0 { | ||
| 96 | t.Fatalf("bob = %+v, want only the one report that was fixed", bob) | ||
| 97 | } | ||
| 98 | if carol := sums["carol"]; carol.Reviews != 1 { | ||
| 99 | t.Fatalf("carol = %+v, want one review that gated the landing", carol) | ||
| 100 | } | ||
| 101 | // Dave's review was of an old head, his report was closed by hand, and his | ||
| 102 | // change never landed. There is no row to show. | ||
| 103 | if dave, ok := sums["dave"]; ok { | ||
| 104 | t.Fatalf("dave = %+v, want no counted work", dave) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | func TestContributorSummariesIsOneRepository(t *testing.T) { | ||
| 109 | t.Parallel() | ||
| 110 | s, m, g := incrementalRepo(t) | ||
| 111 | other, og := newSecondRepo(t, s, "other") | ||
| 112 | now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) | ||
| 113 | |||
| 114 | landed := func(repo *git.Repo, author string) { | ||
| 115 | t.Helper() | ||
| 116 | req, err := metadata.NewRequest("landed in "+author, "why", author, "main", requestHead, now) | ||
| 117 | if err != nil { | ||
| 118 | t.Fatal(err) | ||
| 119 | } | ||
| 120 | if err := metadata.CreateRequest(repo, req, author); err != nil { | ||
| 121 | t.Fatal(err) | ||
| 122 | } | ||
| 123 | if _, _, err := metadata.MarkMerged(repo, req.ID, requestHead, requestHead, requestHead, author, now); err != nil { | ||
| 124 | t.Fatal(err) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | landed(g, "alice") | ||
| 128 | landed(og, "bob") | ||
| 129 | |||
| 130 | if err := s.Ensure(m); err != nil { | ||
| 131 | t.Fatal(err) | ||
| 132 | } | ||
| 133 | if err := s.Ensure(other); err != nil { | ||
| 134 | t.Fatal(err) | ||
| 135 | } | ||
| 136 | |||
| 137 | demo, err := s.ContributorSummaries(m.ID) | ||
| 138 | if err != nil { | ||
| 139 | t.Fatal(err) | ||
| 140 | } | ||
| 141 | if demo["alice"].Merged != 1 { | ||
| 142 | t.Fatalf("demo = %+v, want alice's one merged change", demo) | ||
| 143 | } | ||
| 144 | if _, ok := demo["bob"]; ok { | ||
| 145 | t.Fatalf("demo inherited the other repository's work: %+v", demo) | ||
| 146 | } | ||
| 147 | |||
| 148 | otherSums, err := s.ContributorSummaries(other.ID) | ||
| 149 | if err != nil { | ||
| 150 | t.Fatal(err) | ||
| 151 | } | ||
| 152 | if otherSums["bob"].Merged != 1 { | ||
| 153 | t.Fatalf("other = %+v, want bob's one merged change", otherSums) | ||
| 154 | } | ||
| 155 | if _, ok := otherSums["alice"]; ok { | ||
| 156 | t.Fatalf("the other repository inherited demo's work: %+v", otherSums) | ||
| 157 | } | ||
| 158 | } | ||