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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 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 | |
| 21 | "rodent.software/vole/ci/runs" |
| 22 | "rodent.software/vole/forge/access" |
| 23 | "rodent.software/vole/forge/repo" |
| 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 |
| 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 |
| 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 | } |
| 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 | } |
| 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 |
| 181 | case runs.StatusError: |
| 182 | checks.Broken = true |
| 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 | } |
| 239 |