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.
|
7cc36ba0
Keenan Nemetz
4d ago
|
1 | package web |
| 2 | ||
| 3 | // The change list's one status column. | |
| 4 | // | |
| 5 | // A change is not simply open or closed: it is waiting for a maintainer to allow | |
| 6 | // CI, or for jobs to finish, or for a reviewer, or for the branch to be updated. | |
| 7 | // The list says which of those it is, in one word, so a reader can tell what the | |
| 8 | // next move is without opening every change. | |
| 9 | // | |
| 10 | // The answer is derived from facts a list can afford: the approvals it already | |
| 11 | // counted, the runs recorded for the head commit, whether a contributor tip is | |
| 12 | // still waiting to be allowed, and — only for a change that would otherwise read | |
| 13 | // as ready — whether it still merges into its base. The full landing plan belongs | |
| 14 | // to the detail page; nothing here moves a ref or queues a run. | |
| 15 | ||
| 16 | import ( | |
| 17 | "fmt" | |
| 18 | "sort" | |
| 19 | "strings" | |
| 20 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
21 | "rodent.software/vole/ci/runs" |
| 22 | "rodent.software/vole/forge/access" | |
| 23 | "rodent.software/vole/forge/repo" | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
24 | ) |
| 25 | ||
| 26 | // changeStatus is what the list's Status column says about one row. Key styles | |
| 27 | // the label, Label is the words, Icon is the glyph beside them, and Note is the | |
| 28 | // tooltip that says why. | |
| 29 | type changeStatus struct { | |
| 30 | Key string | |
| 31 | Label string | |
| 32 | Icon string | |
| 33 | Note string | |
| 34 | } | |
| 35 | ||
| 36 | // changeChecks reduces the runs for one head commit to the two questions the | |
| 37 | // status asks: is anything still running, and did anything fail. | |
| 38 | type changeChecks struct { | |
| 39 | Pending bool | |
| 40 | Failed bool | |
|
70c9ad4e
Keenan Nemetz
2h ago
|
41 | // Broken says a run for this commit never started, so nothing was tested. It is |
| 42 | // a wait with a note, not a failure: a machine that could not build must not make | |
| 43 | // a passing commit read as a failing one. | |
| 44 | Broken bool | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
45 | } |
| 46 | ||
| 47 | // changeStatusFor picks the single status. The order is the order of the next | |
| 48 | // move: a held CI run blocks everything, a failure is the author's turn, running | |
| 49 | // jobs are only a wait, and review comes after the checks that inform it. | |
| 50 | func changeStatusFor(state, base string, current, required int, checks changeChecks, ciHeld, conflictBase string) changeStatus { | |
| 51 | switch state { | |
| 52 | case "merged": | |
| 53 | return changeStatus{ | |
| 54 | Key: "merged", Label: "Merged", Icon: "git-merge", | |
| 55 | Note: "This change has landed on " + branchName(base) + ".", | |
| 56 | } | |
| 57 | case "closed": | |
| 58 | return changeStatus{ | |
| 59 | Key: "closed", Label: "Closed", Icon: "circle-x", | |
| 60 | Note: "This change was closed without merging.", | |
| 61 | } | |
| 62 | } | |
| 63 | if ciHeld != "" { | |
| 64 | return changeStatus{ | |
| 65 | Key: "waiting", Label: "Waiting on CI approval", Icon: "play", | |
| 66 | Note: "CI is held until a maintainer allows " + ciHeld + " at this commit.", | |
| 67 | } | |
| 68 | } | |
| 69 | if checks.Failed { | |
| 70 | return changeStatus{ | |
| 71 | Key: "failed", Label: "CI failed", Icon: "circle-x", | |
| 72 | Note: "A run for this commit failed. Pushing a fix starts a new review.", | |
| 73 | } | |
| 74 | } | |
|
70c9ad4e
Keenan Nemetz
2h ago
|
75 | if checks.Broken { |
| 76 | return changeStatus{ | |
| 77 | Key: "waiting", Label: "CI could not run", Icon: "info", | |
| 78 | Note: "A runner could not start a job for this commit, so nothing was tested. " + | |
| 79 | "Re-run it once the machine can build; the commit is not what failed.", | |
| 80 | } | |
| 81 | } | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
82 | if checks.Pending { |
| 83 | return changeStatus{ | |
| 84 | Key: "waiting", Label: "Waiting on CI jobs", Icon: "activity", | |
| 85 | Note: "Checks for this commit are queued or running.", | |
| 86 | } | |
| 87 | } | |
| 88 | if current < required { | |
| 89 | return changeStatus{ | |
| 90 | Key: "review", Label: "Waiting for review", Icon: "circle-dot", | |
| 91 | Note: fmt.Sprintf("%d of %d required approvals.", current, required), | |
| 92 | } | |
| 93 | } | |
| 94 | if conflictBase != "" { | |
| 95 | return changeStatus{ | |
| 96 | Key: "conflict", Label: "Merge conflict", Icon: "git-branch", | |
| 97 | Note: "This no longer merges cleanly into " + conflictBase + ". Update the branch.", | |
| 98 | } | |
| 99 | } | |
| 100 | note := "No approval is required and the checks on this commit passed." | |
| 101 | if required > 0 { | |
| 102 | note = "The required approvals are in and the checks on this commit passed." | |
| 103 | } | |
| 104 | return changeStatus{ | |
| 105 | Key: "ready", Label: "Ready to merge", Icon: "circle-check", Note: note, | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | // branchName keeps a note readable when a fixture or an old record names no base. | |
| 110 | func branchName(base string) string { | |
| 111 | if base == "" { | |
| 112 | return "its base branch" | |
| 113 | } | |
| 114 | return base | |
| 115 | } | |
| 116 | ||
| 117 | // decorateChangeStatuses fills the Status of every row. It is called once per | |
| 118 | // page, after the approval counts are known and before the template runs. | |
| 119 | func (w *Web) decorateChangeStatuses(m *repo.Meta, rows []requestRow) { | |
| 120 | if len(rows) == 0 { | |
| 121 | return | |
| 122 | } | |
| 123 | open := false | |
| 124 | for _, row := range rows { | |
| 125 | if row.State == "open" { | |
| 126 | open = true | |
| 127 | break | |
| 128 | } | |
| 129 | } | |
| 130 | held := map[string]string{} | |
| 131 | if open { | |
| 132 | held = w.heldContributorHeads(m) | |
| 133 | } | |
| 134 | checksByHead := w.changeChecks(m, rows) | |
| 135 | for i := range rows { | |
| 136 | row := &rows[i] | |
| 137 | checks := checksByHead[row.Head] | |
| 138 | ciHeld, conflictBase := "", "" | |
| 139 | if row.State == "open" { | |
| 140 | ciHeld = held[row.Head] | |
| 141 | // A conflict only changes the answer for a change that would | |
| 142 | // otherwise read as approved, so the merge is asked for that row and | |
| 143 | // no other. A page of rows waiting for review costs no merges. | |
| 144 | if ciHeld == "" && !checks.Failed && !checks.Pending && row.Current >= row.Required { | |
| 145 | conflictBase = w.changeConflictBase(m, *row) | |
| 146 | } | |
| 147 | } | |
| 148 | row.Status = changeStatusFor(row.State, row.Base, row.Current, row.Required, checks, ciHeld, conflictBase) | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | // changeChecks reads the newest run per job for every head on the page in one | |
| 153 | // query. A retried job's newest attempt is the one that counts, which is what the | |
| 154 | // store returns. | |
| 155 | func (w *Web) changeChecks(m *repo.Meta, rows []requestRow) map[string]changeChecks { | |
| 156 | out := map[string]changeChecks{} | |
| 157 | if w.Runs == nil { | |
| 158 | return out | |
| 159 | } | |
| 160 | shas := make([]string, 0, len(rows)) | |
| 161 | seen := map[string]bool{} | |
| 162 | for _, row := range rows { | |
| 163 | if row.Head == "" || seen[row.Head] { | |
| 164 | continue | |
| 165 | } | |
| 166 | seen[row.Head] = true | |
| 167 | shas = append(shas, row.Head) | |
| 168 | } | |
| 169 | bySHA, err := w.Runs.ForSHAs(m.Name, shas) | |
| 170 | if err != nil { | |
| 171 | return out | |
| 172 | } | |
| 173 | for sha, byKey := range bySHA { | |
| 174 | var checks changeChecks | |
| 175 | for _, run := range byKey { | |
| 176 | switch run.Status { | |
| 177 | case runs.StatusFailed, runs.StatusCanceled: | |
| 178 | checks.Failed = true | |
| 179 | case runs.StatusQueued, runs.StatusRunning: | |
| 180 | checks.Pending = true | |
|
70c9ad4e
Keenan Nemetz
2h ago
|
181 | case runs.StatusError: |
| 182 | checks.Broken = true | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
183 | } |
| 184 | } | |
| 185 | out[sha] = checks | |
| 186 | } | |
| 187 | return out | |
| 188 | } | |
| 189 | ||
| 190 | // heldContributorHeads names the contributor branch behind each head whose CI is | |
| 191 | // still waiting for a maintainer. One ref listing answers for the whole page, and | |
| 192 | // the first branch at a tip decides, which is the rule the change page uses. | |
| 193 | func (w *Web) heldContributorHeads(m *repo.Meta) map[string]string { | |
| 194 | held := map[string]string{} | |
| 195 | if w.Runs == nil { | |
| 196 | return held | |
| 197 | } | |
| 198 | refs, err := m.Git().ListRefs("refs/heads/") | |
| 199 | if err != nil { | |
| 200 | return held | |
| 201 | } | |
| 202 | names := make([]string, 0, len(refs)) | |
| 203 | for ref := range refs { | |
| 204 | names = append(names, ref) | |
| 205 | } | |
| 206 | sort.Strings(names) | |
| 207 | for _, ref := range names { | |
| 208 | if _, ok := access.ContributorRefName(ref); !ok { | |
| 209 | continue | |
| 210 | } | |
| 211 | head := refs[ref] | |
| 212 | if _, seen := held[head]; seen { | |
| 213 | continue | |
| 214 | } | |
| 215 | if allowed, err := w.Runs.CIAllowed(m.Name, ref, head); err == nil && !allowed { | |
| 216 | held[head] = strings.TrimPrefix(ref, "refs/heads/") | |
| 217 | } | |
| 218 | } | |
| 219 | return held | |
| 220 | } | |
| 221 | ||
| 222 | // changeConflictBase returns the base branch when this change no longer merges | |
| 223 | // into it, and "" when it does or when the question cannot be answered. It is | |
| 224 | // the same check the change page's Review section uses, asked for one row. | |
| 225 | func (w *Web) changeConflictBase(m *repo.Meta, row requestRow) string { | |
| 226 | if row.Base == "" || row.Head == "" { | |
| 227 | return "" | |
| 228 | } | |
| 229 | baseTip, err := m.Git().RevParse("refs/heads/" + row.Base) | |
| 230 | if err != nil || baseTip == "" { | |
| 231 | return "" | |
| 232 | } | |
| 233 | _, conflicts, err := m.Git().MergeTree(baseTip, row.Head) | |
| 234 | if err != nil || len(conflicts) == 0 { | |
| 235 | return "" | |
| 236 | } | |
| 237 | return row.Base | |
| 238 | } |