Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Blame · server/web/change_status.go

Line attribution on main · 238 line(s)

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 }