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.
Add vole change merge, over an SSH verb
b14a78191909907be833d27d82d215a0319de9e3 · Keenan Nemetz · 14d ago · parent 38b30251
Add vole change merge, over an SSH verb The landing operation existed but only the browser could reach it. The CLI cannot do it from a clone: whether a landing is allowed depends on the repository's ACL, the approvals filed against the request, and the run results, and none of those are in the working tree. So the CLI asks the instance, over the key it already pushes with. The new verb takes a repository path and a request id and answers one line; the server resolves the rules, runs forge/landing.Land, and writes the merge line to the audit log the same way a push does. Merging is the approve capability, and the author is not excluded: landing your own request once somebody else approved it is the point of separating the two. Whether an approval exists is the gate's question, asked inside Land, so the CLI, the browser button and the pre-receive hook cannot disagree about it. The verb accepts the same remote path shape as the git verbs and runs it through the same repository-name check, so a name that would be refused on a push is refused here too. Closes #01M5XF4XWHVMMCQEYENA8X32GK.
server/ssh/merge.go +78
| @@ -0,0 +1,78 @@ | |||
| 1 | package ssh | ||
| 2 | |||
| 3 | // `vole change merge` over the SSH channel. | ||
| 4 | // | ||
| 5 | // Landing moves a branch, so it has to happen where the rules live: the ACL, the | ||
| 6 | // approval files and the run results are all instance-side, and a clone holds none | ||
| 7 | // of them. The CLI therefore asks the server, over the key it already pushes with, | ||
| 8 | // and the server runs forge/landing.Land — the same operation the browser's merge | ||
| 9 | // button runs and the same gate the pre-receive hook applies to a push. | ||
| 10 | // | ||
| 11 | // This is not a shell. The verb takes a repository name and a request id, nothing | ||
| 12 | // else, and answers one line. | ||
| 13 | |||
| 14 | import ( | ||
| 15 | "fmt" | ||
| 16 | "strings" | ||
| 17 | "time" | ||
| 18 | |||
| 19 | xssh "golang.org/x/crypto/ssh" | ||
| 20 | |||
| 21 | "github.com/rodent-software/vole/ci/runs" | ||
| 22 | "github.com/rodent-software/vole/forge/landing" | ||
| 23 | "github.com/rodent-software/vole/internal/format" | ||
| 24 | ) | ||
| 25 | |||
| 26 | // serveChangeMerge runs one landing for the caller. Every refusal is written to the | ||
| 27 | // channel with a "vole:" prefix, which is what the client turns into the error. | ||
| 28 | func (s *Server) serveChangeMerge(ch xssh.Channel, argv []string, user, remote string) int { | ||
| 29 | if len(argv) != 3 { | ||
| 30 | fmt.Fprint(ch, "vole: usage: vole-change-merge <repository> <request-id>\n") | ||
| 31 | return 129 | ||
| 32 | } | ||
| 33 | name, ok := repoName(argv[1]) | ||
| 34 | if !ok { | ||
| 35 | fmt.Fprint(ch, "vole: invalid repository name\n") | ||
| 36 | return 129 | ||
| 37 | } | ||
| 38 | id := strings.TrimSpace(argv[2]) | ||
| 39 | m, err := s.cfg.Repos.Open(name) | ||
| 40 | if err != nil { | ||
| 41 | fmt.Fprintf(ch, "vole: repository %q not found\n", name) | ||
| 42 | return 1 | ||
| 43 | } | ||
| 44 | a, err := m.ACL() | ||
| 45 | if err != nil { | ||
| 46 | fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name) | ||
| 47 | return 1 | ||
| 48 | } | ||
| 49 | // Merging is the approve capability: landing is the act of accepting the review. | ||
| 50 | // The author may do it once somebody else approved; whether enough approvals | ||
| 51 | // exist is the gate's question, asked inside Land. | ||
| 52 | if !a.CanTriage(user) { | ||
| 53 | fmt.Fprintf(ch, "vole: %s may not merge change requests in %s\n", user, name) | ||
| 54 | return 1 | ||
| 55 | } | ||
| 56 | gate := landing.Gate{ | ||
| 57 | Repo: m.Git(), Name: m.Name, ACL: a, Actor: user, | ||
| 58 | Role: a.EffectiveRole(user, s.cfg.Identity.IsAdmin(user)), | ||
| 59 | Runs: func() (*runs.Store, error) { return s.cfg.Runs, nil }, | ||
| 60 | } | ||
| 61 | res, err := landing.Land(gate, id, user, time.Now().UTC()) | ||
| 62 | if err != nil { | ||
| 63 | fmt.Fprintf(ch, "vole: %v\n", err) | ||
| 64 | return 1 | ||
| 65 | } | ||
| 66 | switch { | ||
| 67 | case res.AlreadyLanded: | ||
| 68 | fmt.Fprintf(ch, "already merged: %s at %s\n", id, format.SHA(res.Request.MergedHead)) | ||
| 69 | case res.FastForward: | ||
| 70 | fmt.Fprintf(ch, "merged %s into %s at %s\n", id, res.Base, format.SHA(res.NewTip)) | ||
| 71 | default: | ||
| 72 | fmt.Fprintf(ch, "merged %s into %s at %s (merge commit %s)\n", | ||
| 73 | id, res.Base, format.SHA(res.NewTip), format.SHA(res.Request.Head)) | ||
| 74 | } | ||
| 75 | s.cfg.Log.Info("ssh: merged a change request", "user", user, "repo", name, | ||
| 76 | "request", id, "base", res.Base, "tip", res.NewTip) | ||
| 77 | return 0 | ||
| 78 | } | ||