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.
server/web/overview.go +126−29
| @@ -32,11 +32,60 @@ type languageShare struct { | |||
| 32 | 32 | Percent int | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | // contributorView is one account in the overview's short list. The counts are | ||
| 36 | // the repository's own, so the same account can rank differently in another | ||
| 37 | // repository. | ||
| 35 | 38 | type contributorView struct { | |
| 36 | Name string | ||
| 37 | Commits int | ||
| 39 | Name string | ||
| 40 | Merged int | ||
| 41 | Fixed int | ||
| 42 | Reported int | ||
| 43 | Reviews int | ||
| 44 | Bot bool | ||
| 45 | } | ||
| 46 | |||
| 47 | func (c contributorView) total() int { return c.Merged + c.Fixed + c.Reported + c.Reviews } | ||
| 48 | |||
| 49 | // contributorStat is one icon beside an account's name. The label is the whole | ||
| 50 | // sentence the icon stands for, shown as a tooltip and read by assistive tech; | ||
| 51 | // a zero count is left out rather than drawn as an empty mark. | ||
| 52 | type contributorStat struct { | ||
| 53 | Icon string | ||
| 54 | Count int | ||
| 55 | Label string | ||
| 56 | } | ||
| 57 | |||
| 58 | // Stats returns the counts that are not zero, in the order the section explains | ||
| 59 | // them. The labels name the outcome, not the record behind it, and each count is | ||
| 60 | // pluralised on its own so "1 issue reported" does not read like a schema dump. | ||
| 61 | func (c contributorView) Stats() []contributorStat { | ||
| 62 | all := []contributorStat{ | ||
| 63 | {Icon: "git-merge", Count: c.Merged, Label: countLabel(c.Merged, "change merged", "changes merged")}, | ||
| 64 | {Icon: "circle-check", Count: c.Fixed, Label: countLabel(c.Fixed, "issue fixed", "issues fixed")}, | ||
| 65 | {Icon: "circle-dot", Count: c.Reported, Label: countLabel(c.Reported, "issue reported", "issues reported")}, | ||
| 66 | {Icon: "check", Count: c.Reviews, Label: countLabel(c.Reviews, "review", "reviews")}, | ||
| 67 | } | ||
| 68 | out := all[:0] | ||
| 69 | for _, stat := range all { | ||
| 70 | if stat.Count > 0 { | ||
| 71 | out = append(out, stat) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | return out | ||
| 75 | } | ||
| 76 | |||
| 77 | func countLabel(n int, one, many string) string { | ||
| 78 | if n == 1 { | ||
| 79 | return "1 " + one | ||
| 80 | } | ||
| 81 | return fmt.Sprintf("%d %s", n, many) | ||
| 38 | 82 | } | |
| 39 | 83 | ||
| 84 | // overviewContributorLimit is how many accounts the sidebar names. The list is | ||
| 85 | // a pointer to the work, not a directory of everybody who ever touched the | ||
| 86 | // repository. | ||
| 87 | const overviewContributorLimit = 8 | ||
| 88 | |||
| 40 | 89 | // overviewEntity is the small shape the overview's issue and change sections | |
| 41 | 90 | // need: enough to link, title, name the author and say how old it is. The list | |
| 42 | 91 | // pages carry richer rows; the sidebar says only what fits beside a README. | |
| @@ -64,14 +113,16 @@ func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) { | |||
| 64 | 113 | // a packed ref, and the answer is what the index's summary is checked against. | |
| 65 | 114 | commit, _ := g.RefCommit("refs/heads/" + ref) | |
| 66 | 115 | ||
| 67 | commits, languages, contributors, tags := w.overviewSummaries(memoOf(r), m, commit) | ||
| 116 | memo := memoOf(r) | ||
| 117 | commits, languages, tags := w.overviewSummaries(memo, m, commit) | ||
| 118 | contributors := w.overviewContributors(memo, m) | ||
| 68 | 119 | ||
| 69 | 120 | var recentRuns []*runs.Run | |
| 70 | 121 | if w.Runs != nil { | |
| 71 | 122 | recentRuns, _ = w.Runs.Recent(m.Name, 5) | |
| 72 | 123 | } | |
| 73 | 124 | ||
| 74 | recentIssues, recentRequests := w.overviewRecent(memoOf(r), m) | ||
| 125 | recentIssues, recentRequests := w.overviewRecent(memo, m) | ||
| 75 | 126 | ||
| 76 | 127 | readme, readmeName := w.readmeAt(m, ref, commit) | |
| 77 | 128 | ||
| @@ -107,12 +158,12 @@ func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) { | |||
| 107 | 158 | } | |
| 108 | 159 | ||
| 109 | 160 | // overviewSummaries returns the parts of the overview that describe the default | |
| 110 | // branch: its newest commits, its languages, its authors, and the repository's | ||
| 111 | // tags. When the index has the summary for this exact tip they are rows; when it | ||
| 112 | // does not (no index, or a tip the push hook has not recorded) they are read from | ||
| 113 | // git, which is the fallback that keeps the page correct. | ||
| 161 | // branch: its newest commits, its languages, and the repository's tags. When the | ||
| 162 | // index has the summary for this exact tip they are rows; when it does not (no | ||
| 163 | // index, or a tip the push hook has not recorded) they are read from git, which | ||
| 164 | // is the fallback that keeps the page correct. | ||
| 114 | 165 | func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) ( | |
| 115 | commits []git.Commit, languages []languageShare, contributors []contributorView, tags []git.Tag) { | ||
| 166 | commits []git.Commit, languages []languageShare, tags []git.Tag) { | ||
| 116 | 167 | g := m.Git() | |
| 117 | 168 | if w.Index != nil { | |
| 118 | 169 | if err := w.ensureIndex(memo, m); err == nil { | |
| @@ -126,12 +177,6 @@ func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) | |||
| 126 | 177 | if shares, err := w.Index.Languages(e.ID); err == nil { | |
| 127 | 178 | languages = languageShares(shares) | |
| 128 | 179 | } | |
| 129 | if list, err := w.Index.Contributors(e.ID, 8); err == nil { | ||
| 130 | contributors = make([]contributorView, 0, len(list)) | ||
| 131 | for _, c := range list { | ||
| 132 | contributors = append(contributors, contributorView{Name: c.Name, Commits: c.Commits}) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | 180 | if list, err := w.Index.Tags(e.ID, 5); err == nil { | |
| 136 | 181 | tags = make([]git.Tag, 0, len(list)) | |
| 137 | 182 | for _, t := range list { | |
| @@ -142,9 +187,9 @@ func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) | |||
| 142 | 187 | } | |
| 143 | 188 | } | |
| 144 | 189 | // A repository with commits has at least one of each; an empty | |
| 145 | // section means the summary was written without it (a tree walk or | ||
| 146 | // a shortlog that failed), so read that part from git rather than | ||
| 147 | // leave the page blank until the next push. | ||
| 190 | // section means the summary was written without it (a tree walk | ||
| 191 | // that failed), so read that part from git rather than leave the | ||
| 192 | // page blank until the next push. | ||
| 148 | 193 | if commit != "" { | |
| 149 | 194 | if len(commits) == 0 { | |
| 150 | 195 | commits, _ = g.Log(commit, 5) | |
| @@ -152,14 +197,8 @@ func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) | |||
| 152 | 197 | if len(languages) == 0 { | |
| 153 | 198 | languages = w.languages(g, commit) | |
| 154 | 199 | } | |
| 155 | if len(contributors) == 0 { | ||
| 156 | contributors = w.contributors(g, commit) | ||
| 157 | if len(contributors) > 8 { | ||
| 158 | contributors = contributors[:8] | ||
| 159 | } | ||
| 160 | } | ||
| 161 | 200 | } | |
| 162 | return commits, languages, contributors, tags | ||
| 201 | return commits, languages, tags | ||
| 163 | 202 | } | |
| 164 | 203 | } | |
| 165 | 204 | } | |
| @@ -173,11 +212,69 @@ func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) | |||
| 173 | 212 | } | |
| 174 | 213 | tags = list | |
| 175 | 214 | } | |
| 176 | contributors = w.contributors(g, commit) | ||
| 177 | if len(contributors) > 8 { | ||
| 178 | contributors = contributors[:8] | ||
| 215 | return commits, languages, tags | ||
| 216 | } | ||
| 217 | |||
| 218 | // overviewContributors ranks the accounts whose work the metadata branch | ||
| 219 | // records: changes that merged, issues those changes fixed, issues the account | ||
| 220 | // reported that were fixed, and reviews that named the merged head. Commit | ||
| 221 | // authors are deliberately absent: a git name is text somebody typed, not an | ||
| 222 | // account, and there is no field that joins the two. | ||
| 223 | func (w *Web) overviewContributors(memo *requestMemo, m *repo.Meta) []contributorView { | ||
| 224 | if w.Index == nil { | ||
| 225 | return nil | ||
| 179 | 226 | } | |
| 180 | return commits, languages, contributors, tags | ||
| 227 | if err := w.ensureIndex(memo, m); err != nil { | ||
| 228 | return nil | ||
| 229 | } | ||
| 230 | sums, err := w.Index.ContributorSummaries(m.ID) | ||
| 231 | if err != nil { | ||
| 232 | return nil | ||
| 233 | } | ||
| 234 | list := make([]contributorView, 0, len(sums)) | ||
| 235 | for name, sum := range sums { | ||
| 236 | view := contributorView{ | ||
| 237 | Name: name, Merged: sum.Merged, Fixed: sum.Fixed, | ||
| 238 | Reported: sum.Reported, Reviews: sum.Reviews, | ||
| 239 | } | ||
| 240 | if w.Ident != nil { | ||
| 241 | // A name in metadata that the roster does not know is not an | ||
| 242 | // account. It can be left behind by a rewrite or a hand-edited | ||
| 243 | // branch, and the page should not present it as a person. | ||
| 244 | acct, err := w.Ident.Find(name) | ||
| 245 | if err != nil { | ||
| 246 | continue | ||
| 247 | } | ||
| 248 | view.Name = acct.Name | ||
| 249 | view.Bot = acct.Bot() | ||
| 250 | } | ||
| 251 | if view.total() == 0 { | ||
| 252 | continue | ||
| 253 | } | ||
| 254 | list = append(list, view) | ||
| 255 | } | ||
| 256 | sort.SliceStable(list, func(i, j int) bool { | ||
| 257 | if a, b := list[i].total(), list[j].total(); a != b { | ||
| 258 | return a > b | ||
| 259 | } | ||
| 260 | if list[i].Merged != list[j].Merged { | ||
| 261 | return list[i].Merged > list[j].Merged | ||
| 262 | } | ||
| 263 | if list[i].Fixed != list[j].Fixed { | ||
| 264 | return list[i].Fixed > list[j].Fixed | ||
| 265 | } | ||
| 266 | if list[i].Reported != list[j].Reported { | ||
| 267 | return list[i].Reported > list[j].Reported | ||
| 268 | } | ||
| 269 | if list[i].Reviews != list[j].Reviews { | ||
| 270 | return list[i].Reviews > list[j].Reviews | ||
| 271 | } | ||
| 272 | return list[i].Name < list[j].Name | ||
| 273 | }) | ||
| 274 | if len(list) > overviewContributorLimit { | ||
| 275 | list = list[:overviewContributorLimit] | ||
| 276 | } | ||
| 277 | return list | ||
| 181 | 278 | } | |
| 182 | 279 | ||
| 183 | 280 | // overviewRecent returns the newest open issues and change requests for the | |