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.

Land a change request on the server instead of by pushing

38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 13d ago · parent 536f08b9

Land a change request on the server instead of by pushing

A change request could only be landed by merging locally and pushing the
base branch: the web page was a review surface with no way to finish, and
nothing recorded that the request had landed. `state: merged` was already
accepted by the request reader and rendered by the activity feed, but
nothing ever wrote it, so a merged request and an abandoned one looked
identical.

The gate that decides a landing lives in the pre-receive hook, and the
hook only sees pushes. A merge performed inside the server moves the ref
without the hook, so a server-side merge either re-runs that rule or
becomes the easiest way to land an unapproved change. It now re-runs it:
the approval and check rule moved to forge/landing, and both the hook and
the new landing operation call the same Check.

Landing is fast-forward when the base is an ancestor of the reviewed head,
so the commit that passed CI is the commit that lands; otherwise a
two-parent merge commit, which is the only other shape the gate accepts.
Conflicts are refused with the conflicting paths, never resolved. The ref
moves by compare-and-swap, so a concurrent push loses cleanly and the
landing retries; the request is marked merged after the ref moves, which
makes a retry after a crash finish the marking instead of failing.

The request now records merged_head, merged_by and merged_at, and the
reader refuses a merged request that names no commit, and refuses those
fields on a request that is not merged. "merged" is no longer settable
through the generic state writer: it has to travel with the commit.

The browser gets a Merge button that preflights the same gate, so it only
appears when the landing would succeed, and the page shows what landed,
who landed it, and when. `vole change merge` follows in its own change:
the CLI has no access to the ACL or the run queue, so it needs a verb on
the SSH channel, and that transport is worth reviewing on its own.

Closes the landing half of #01M5XF4XWHVMMCQEYENA8X32GK.

server/hook/checks.go −110

@@ -1,110 +0,0 @@
1 package hook
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/rodent-software/vole/ci"
8 "github.com/rodent-software/vole/ci/runs"
9 "github.com/rodent-software/vole/core/git"
10 "github.com/rodent-software/vole/forge/access"
11 "github.com/rodent-software/vole/internal/format"
12 )
13
14 // checkGaps reports which required CI jobs have not passed for a commit.
15 //
16 // The question is asked of the commit being landed — the approved head — not of
17 // the merge commit a person may be pushing, because CI runs on what was reviewed;
18 // a merge commit is a new object nobody built.
19 // checkGaps reports what a protected branch is still waiting for. A green run only
20 // counts when the host has allowed the account that reported it: a required check is
21 // a claim about a commit, and without that rule any writer could satisfy it by
22 // running CI on their own machine and saying it passed.
23 func checkGaps(g *git.Repo, runsDB, repo, sha, branch string, a *access.ACL) ([]string, error) {
24 wfs, problems := ci.At(g, sha)
25 if len(problems) > 0 {
26 return nil, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; "))
27 }
28 var required []*ci.Workflow
29 for _, wf := range wfs {
30 if wf.Applies(branch) {
31 required = append(required, wf)
32 }
33 }
34 if len(required) == 0 {
35 return nil, nil // nothing to run, so nothing to wait for
36 }
37 if runsDB == "" {
38 return nil, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
39 }
40 store, err := runs.Open(runsDB)
41 if err != nil {
42 return nil, fmt.Errorf("cannot open the run queue: %w", err)
43 }
44 defer store.Close()
45
46 results, err := store.ForSHA(repo, sha)
47 if err != nil {
48 return nil, fmt.Errorf("cannot read run results: %w", err)
49 }
50
51 var gaps []string
52 for _, wf := range required {
53 for _, job := range wf.Jobs {
54 run := results[runs.Key(wf.Path, job.ID)]
55 switch {
56 case run == nil:
57 gaps = append(gaps, fmt.Sprintf(
58 "%s: job %q has no run for %s — push the branch and let CI run on it "+
59 "(this job filters on [%s]; a commit tested only on other branches has no result here)",
60 wf.Path, job.ID, format.SHA(sha), strings.Join(wf.Branches, " ")))
61 case run.Status == runs.StatusFailed:
62 gaps = append(gaps, fmt.Sprintf(
63 "%s: job %q failed for %s (exit %d)", wf.Path, job.ID, format.SHA(sha), run.ExitCode))
64 case run.Status == runs.StatusCanceled:
65 gaps = append(gaps, fmt.Sprintf(
66 "%s: job %q was canceled for %s", wf.Path, job.ID, format.SHA(sha)))
67 case run.Status != runs.StatusSuccess:
68 gaps = append(gaps, fmt.Sprintf(
69 "%s: job %q is %s for %s; wait for it to finish", wf.Path, job.ID, run.Status, format.SHA(sha)))
70 }
71 if run == nil || run.Status != runs.StatusSuccess {
72 continue
73 }
74 // The result has to be attributable to somebody the host allowed, or it
75 // is a green light from a party with nothing at stake in this branch.
76 att, err := store.Attestation(run.ID)
77 if err != nil {
78 return nil, fmt.Errorf("cannot read who reported the run for %s: %w", format.SHA(sha), err)
79 }
80 switch {
81 case att == nil:
82 gaps = append(gaps, fmt.Sprintf(
83 "%s: job %q passed for %s but the result is unsigned, so it cannot count as a check; "+
84 "re-run it with a current `vole runner`", wf.Path, job.ID, format.SHA(sha)))
85 case a != nil && !a.CanReport(att.Signer):
86 gaps = append(gaps, fmt.Sprintf(
87 "%s: job %q passed for %s, but %s reported it and is not a runner for this "+
88 "repository (runners: %s) — so the result is recorded and does not count. "+
89 "An admin can allow that account with `vole admin acl runners add %s %s`, "+
90 "or a permitted runner can run the job",
91 wf.Path, job.ID, format.SHA(sha), att.Signer,
92 runnerSummary(a), repo, att.Signer))
93 }
94 }
95 }
96 return gaps, nil
97 }
98
99 // runnerSummary renders the allowlist for a message. Admins are named as implicit
100 // because an operator reading the refusal needs to know who already counts.
101 func runnerSummary(a *access.ACL) string {
102 if len(a.Runners) == 0 {
103 return "none named, so only admins"
104 }
105 out := make([]string, 0, len(a.Runners))
106 for _, r := range a.Runners {
107 out = append(out, r)
108 }
109 return strings.Join(out, ", ") + " and admins"
110 }