Commit 08b325bb1d

08b325bb1d221743ebfd2d99502f63b59ff6f346

parent: d8bcf1584c

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 23:11 UTC

M7: merge requests

- repo fork (objects copied via local fetch, fork_of recorded); mr
  create/list/show/diff/comment/review/merge/close; same-repo and
  cross-fork sources (source must be the target or a fork of it)
- refs/merge-requests/N/head lives in the TARGET repo, populated by
  fetching from the source: fork deletion leaves the MR diff intact
  and still mergeable (marked source_gone; branch reappearing reopens)
- post-receive cross-repo effect: source-branch pushes refresh MR
  heads and mark reviews at other heads stale, in one transaction
- merge strategies ff + merge-commit with update-ref CAS against
  concurrent pushes; merge-commit identity is the merging user's
  verified primary email; no server signing key
- require_signed_commits: push-time enforcement via two-phase hook
  (daemon requests raw commit objects from the quarantine-side hook);
  merge-time policy allows only ff of all-verified commits, refusing
  merge commits with exit 4 and rebase instructions
- web read views: /mrs list and /mrs/{n} detail with diff and stale
  markers; diff classification shared with the commit page

Layout: unified · split

cmd/forged/hook.go +33
@@ -4,6 +4,7 @@ import (
44 "bufio"
55 "fmt"
66 "os"
7 "os/exec"
78 "strconv"
89 "strings"
910
@@ -14,6 +15,36 @@ import (
1415 "github.com/krazywarez/forge/internal/policy"
1516)
1617
18// collectIncomingCommits lists the commits this push introduces and reads
19// their raw objects. It runs in the hook process, which inherits git's
20// quarantine environment — the daemon cannot see these objects yet.
21func collectIncomingCommits(updates []policy.RefUpdate) (hookd.CommitsPayload, error) {
22 seen := map[string]bool{}
23 var payload hookd.CommitsPayload
24 for _, u := range updates {
25 if u.IsDelete {
26 continue
27 }
28 // Everything reachable from the new tip that no existing ref has.
29 out, err := exec.Command("git", "rev-list", u.New, "--not", "--all").Output()
30 if err != nil {
31 return payload, fmt.Errorf("rev-list %s: %w", u.New, err)
32 }
33 for _, sha := range strings.Fields(string(out)) {
34 if seen[sha] {
35 continue
36 }
37 seen[sha] = true
38 raw, err := exec.Command("git", "cat-file", "commit", sha).Output()
39 if err != nil {
40 return payload, fmt.Errorf("cat-file %s: %w", sha, err)
41 }
42 payload.Commits = append(payload.Commits, hookd.RawCommit{SHA: sha, Raw: raw})
43 }
44 }
45 return payload, nil
46}
47
1748// hookCmd runs inside a git hook. It computes git facts here — the hook
1849// process inherits git's quarantine environment, so incoming objects are
1950// visible — and asks the daemon for a policy decision over the unix socket.
@@ -57,6 +88,8 @@ func hookCmd() *cobra.Command {
5788 RepoID: repoID,
5889 UserID: userID,
5990 Updates: updates,
91 }, func() (hookd.CommitsPayload, error) {
92 return collectIncomingCommits(updates)
6093 })
6194 if err != nil {
6295 return fmt.Errorf("forge daemon unreachable: %w", err)
cmd/forged/main.go +1 −1
@@ -112,7 +112,7 @@ func serveCmd() *cobra.Command {
112112 if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
113113 return err
114114 }
115 stopHookd, err := hookd.Serve(cfg.Server.Root, st)
115 stopHookd, err := hookd.Serve(cfg, st)
116116 if err != nil {
117117 return err
118118 }
e2e/mr_test.go added +260
@@ -0,0 +1,260 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "golang.org/x/crypto/ssh"
11
12 "github.com/krazywarez/forge/internal/sig"
13)
14
15type mrShow struct {
16 Number int64 `json:"number"`
17 State string `json:"state"`
18 Source string `json:"source"`
19 HeadSHA string `json:"head_sha"`
20 Reviews []struct {
21 Reviewer string `json:"reviewer"`
22 Verdict string `json:"verdict"`
23 Stale bool `json:"stale"`
24 } `json:"reviews"`
25}
26
27func (i *instance) mrShow(t *testing.T, key, repo, n string) mrShow {
28 t.Helper()
29 out, errOut, code := i.ssh(t, key, "", "mr", "show", repo, n, "--json")
30 if code != 0 {
31 t.Fatalf("mr show: exit %d, %s", code, errOut)
32 }
33 var env struct {
34 Data mrShow `json:"data"`
35 }
36 if err := json.Unmarshal([]byte(out), &env); err != nil {
37 t.Fatalf("mr show JSON: %v\n%s", err, out)
38 }
39 return env.Data
40}
41
42func TestMergeRequests(t *testing.T) {
43 inst := startInstance(t)
44
45 aliceKey := inst.newKey(t, "alice")
46 bobKey := inst.newKey(t, "bob")
47 inst.admin(t, "admin", "user", "create", "alice",
48 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
49 inst.admin(t, "admin", "user", "create", "bob",
50 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
51
52 // Alice's upstream repo with an initial commit.
53 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
54 t.Fatalf("repo create: %s", errOut)
55 }
56 aliceEnv := inst.gitEnv(aliceKey)
57 aliceWork := t.TempDir()
58 mustGit(t, aliceWork, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
59 aliceDir := filepath.Join(aliceWork, "w")
60 os.WriteFile(filepath.Join(aliceDir, "lib.txt"), []byte("v1\n"), 0o644)
61 mustGit(t, aliceDir, aliceEnv, "checkout", "-q", "-b", "main")
62 mustGit(t, aliceDir, aliceEnv, "add", ".")
63 mustGit(t, aliceDir, aliceEnv, "commit", "-q", "-m", "base")
64 mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
65
66 // Bob forks and pushes a feature branch to his fork.
67 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/lib"); code != 0 {
68 t.Fatalf("fork: %s", errOut)
69 }
70 bobEnv := inst.gitEnv(bobKey)
71 bobWork := t.TempDir()
72 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("bob/lib"), "w")
73 bobDir := filepath.Join(bobWork, "w")
74 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
75 os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
76 mustGit(t, bobDir, bobEnv, "add", ".")
77 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
78 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
79
80 // MR from the fork into alice/lib.
81 out, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
82 "--source", "bob/lib:feature", "--target", "main", "--title", "'add feature'", "--json")
83 if code != 0 {
84 t.Fatalf("mr create: %s", errOut)
85 }
86 if !strings.Contains(out, `"number":1`) {
87 t.Fatalf("mr create output: %s", out)
88 }
89
90 // The MR head ref is fetchable from the TARGET repo by a reader.
91 fetchDir := t.TempDir()
92 mustGit(t, fetchDir, aliceEnv, "clone", "-q", inst.sshURL("alice/lib"), "c")
93 mustGit(t, filepath.Join(fetchDir, "c"), aliceEnv, "fetch", "-q", "origin", "refs/merge-requests/1/head")
94
95 // Alice approves.
96 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/lib", "1", "--approve"); code != 0 {
97 t.Fatalf("review: %s", errOut)
98 }
99 show := inst.mrShow(t, aliceKey, "alice/lib", "1")
100 if len(show.Reviews) != 1 || show.Reviews[0].Stale {
101 t.Fatalf("fresh review wrong: %+v", show.Reviews)
102 }
103 firstHead := show.HeadSHA
104
105 // Bob force-pushes the source branch: the MR head updates and the
106 // review goes stale.
107 mustGit(t, bobDir, bobEnv, "commit", "-q", "--amend", "-m", "add feature (amended)")
108 mustGit(t, bobDir, bobEnv, "push", "-q", "--force", "origin", "feature")
109 show = inst.mrShow(t, aliceKey, "alice/lib", "1")
110 if show.HeadSHA == firstHead {
111 t.Fatal("MR head not updated after force-push")
112 }
113 if len(show.Reviews) != 1 || !show.Reviews[0].Stale {
114 t.Fatalf("review not marked stale: %+v", show.Reviews)
115 }
116
117 // Target advances, so fast-forward is impossible: default merge makes a
118 // merge commit authored by the merging user.
119 mustGit(t, aliceDir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline moves on")
120 mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
121 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--json")
122 if code != 0 {
123 t.Fatalf("merge: exit %d, %s", code, errOut)
124 }
125 if !strings.Contains(out, `"strategy":"merge"`) {
126 t.Fatalf("expected merge-commit strategy: %s", out)
127 }
128 if inst.mrShow(t, aliceKey, "alice/lib", "1").State != "merged" {
129 t.Fatal("MR not marked merged")
130 }
131 mustGit(t, aliceDir, aliceEnv, "pull", "-q", "origin", "main")
132 if _, err := os.Stat(filepath.Join(aliceDir, "feature.txt")); err != nil {
133 t.Fatal("merged content missing from main")
134 }
135 // The merge commit carries the merging user's identity and is unsigned.
136 tip := strings.TrimSpace(mustGit(t, aliceDir, aliceEnv, "log", "-1", "--format=%an <%ae>"))
137 if tip != "alice <alice@example.test>" {
138 t.Fatalf("merge commit identity: %q", tip)
139 }
140 logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/lib", "--limit", "1")
141 if !strings.Contains(logOut, "unsigned") {
142 t.Fatalf("merge commit should display unsigned:\n%s", logOut)
143 }
144
145 // --- require_signed_commits: push-time and merge-time policy ---
146
147 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/sec"); code != 0 {
148 t.Fatalf("create sec: %s", errOut)
149 }
150 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/sec", "on"); code != 0 {
151 t.Fatalf("require-signed: %s", errOut)
152 }
153 secWork := t.TempDir()
154 mustGit(t, secWork, aliceEnv, "clone", inst.sshURL("alice/sec"), "w")
155 secDir := filepath.Join(secWork, "w")
156
157 // Unsigned push is rejected at pre-receive.
158 os.WriteFile(filepath.Join(secDir, "a.txt"), []byte("a\n"), 0o644)
159 mustGit(t, secDir, aliceEnv, "checkout", "-q", "-b", "main")
160 mustGit(t, secDir, aliceEnv, "add", ".")
161 mustGit(t, secDir, aliceEnv, "commit", "-q", "-m", "unsigned attempt")
162 pushOut, pushCode := gitRun(t, secDir, aliceEnv, "push", "origin", "main")
163 if pushCode == 0 {
164 t.Fatal("unsigned push accepted into require-signed repo")
165 }
166 if !strings.Contains(pushOut, "requires signed commits") {
167 t.Fatalf("unsigned push message:\n%s", pushOut)
168 }
169
170 // SSHSIG-signed commits go through.
171 raw, _ := os.ReadFile(aliceKey)
172 signer, err := ssh.ParsePrivateKey(raw)
173 if err != nil {
174 t.Fatal(err)
175 }
176 signAlice := func(p []byte) string {
177 s, err := sig.MarshalSSHSig(signer, p)
178 if err != nil {
179 t.Fatal(err)
180 }
181 return string(s)
182 }
183 buildCommits(t, secDir, aliceEnv, []commitSpec{
184 {authorEmail: "alice@example.test", subject: "signed base", sign: signAlice},
185 })
186 mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "main")
187
188 // A signed feature branch and a same-repo MR.
189 base := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
190 tree := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main^{tree}"))
191 buildChain(t, secDir, aliceEnv, tree, base, []commitSpec{
192 {authorEmail: "alice@example.test", subject: "signed feature", sign: signAlice},
193 })
194 // buildChain moved refs/heads/main; restore and use a feature branch.
195 feat := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
196 mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/main", base)
197 mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/feat", feat)
198 mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "feat")
199
200 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "create", "alice/sec",
201 "--source", "feat", "--target", "main", "--title", "'signed work'"); code != 0 {
202 t.Fatalf("sec mr create: %s", errOut)
203 }
204
205 // An explicit merge-commit strategy is refused with exit 4 and rebase
206 // instructions.
207 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--strategy", "merge")
208 if code != 4 {
209 t.Fatalf("merge-commit on require-signed: exit %d (want 4), %s", code, errOut)
210 }
211 if !strings.Contains(errOut, "only fast-forward") || !strings.Contains(errOut, "rebase") {
212 t.Fatalf("refusal message: %s", errOut)
213 }
214
215 // Fast-forward merge of verified commits succeeds.
216 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--json")
217 if code != 0 {
218 t.Fatalf("ff merge: %s", errOut)
219 }
220 if !strings.Contains(out, `"strategy":"ff"`) {
221 t.Fatalf("expected ff: %s", out)
222 }
223
224 // --- fork deletion leaves the MR diff intact ---
225
226 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "second", "origin/main")
227 os.WriteFile(filepath.Join(bobDir, "second.txt"), []byte("more\n"), 0o644)
228 mustGit(t, bobDir, bobEnv, "add", ".")
229 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "second feature")
230 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "second")
231 if _, errOut, code = inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
232 "--source", "bob/lib:second", "--target", "main", "--title", "'second'"); code != 0 {
233 t.Fatalf("mr 2 create: %s", errOut)
234 }
235 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "bob/lib", "--yes"); code != 0 {
236 t.Fatalf("fork delete: %s", errOut)
237 }
238 show = inst.mrShow(t, aliceKey, "alice/lib", "2")
239 if show.State != "source_gone" {
240 t.Fatalf("MR 2 state after fork deletion: %s", show.State)
241 }
242 diffOut, errOut, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "2")
243 if code != 0 || !strings.Contains(diffOut, "second.txt") {
244 t.Fatalf("diff after fork deletion: exit %d\n%s%s", code, diffOut, errOut)
245 }
246 // And it can still be merged: the target owns the objects.
247 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2"); code != 0 {
248 t.Fatalf("merge after fork deletion: %s", errOut)
249 }
250
251 // Web read views.
252 status, body := inst.get(t, "/alice/lib/mrs?state=all")
253 if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
254 t.Fatalf("mrs page: %d\n%s", status, body)
255 }
256 status, body = inst.get(t, "/alice/lib/mrs/1")
257 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
258 t.Fatalf("mr detail: %d\n%s", status, body)
259 }
260}
internal/control/mr.go added +595
@@ -0,0 +1,595 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strconv"
8 "strings"
9
10 "github.com/krazywarez/forge/internal/gitutil"
11 "github.com/krazywarez/forge/internal/policy"
12 "github.com/krazywarez/forge/internal/protocol"
13 "github.com/krazywarez/forge/internal/store"
14)
15
16func init() {
17 register(Command{Path: []string{"repo", "fork"},
18 Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork})
19 register(Command{Path: []string{"repo", "settings", "require-signed"},
20 Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
21 register(Command{Path: []string{"mr", "create"},
22 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
23 ReadsStdin: true, Run: runMRCreate})
24 register(Command{Path: []string{"mr", "list"},
25 Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", Run: runMRList})
26 register(Command{Path: []string{"mr", "show"},
27 Summary: "show a merge request: mr show <owner/name> <n>", Run: runMRShow})
28 register(Command{Path: []string{"mr", "diff"},
29 Summary: "show the diff: mr diff <owner/name> <n>", Run: runMRDiff})
30 register(Command{Path: []string{"mr", "comment"},
31 Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
32 ReadsStdin: true, Run: runMRComment})
33 register(Command{Path: []string{"mr", "review"},
34 Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
35 register(Command{Path: []string{"mr", "merge"},
36 Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge]", Run: runMRMerge})
37 register(Command{Path: []string{"mr", "close"},
38 Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose})
39}
40
41func runRepoFork(c *Ctx, args []string) int {
42 var path, name string
43 for i := 0; i < len(args); i++ {
44 switch args[i] {
45 case "--name":
46 if i+1 >= len(args) {
47 return c.fail(protocol.ExitUsage, "--name requires a value")
48 }
49 name = args[i+1]
50 i++
51 default:
52 if path != "" {
53 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
54 }
55 path = args[i]
56 }
57 }
58 if path == "" {
59 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
60 }
61 src, code := resolveRepo(c, path, policy.CanRead)
62 if code >= 0 {
63 return code
64 }
65 if name == "" {
66 name = src.Name
67 }
68 if err := policy.ValidateName(name); err != nil {
69 return c.fail(protocol.ExitUsage, "%v", err)
70 }
71 id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
72 if err != nil {
73 return c.fail(protocol.ExitFailure, "%v", err)
74 }
75 if err := c.Store.SetForkOf(id, src.ID); err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err)
77 }
78 dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
79 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
80 if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
81 c.Store.DeleteRepo(id)
82 return c.fail(protocol.ExitFailure, "%v", err)
83 }
84 if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
85 // Empty source repos have nothing to fetch; that is fine.
86 if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
87 c.Store.DeleteRepo(id)
88 return c.fail(protocol.ExitFailure, "copying refs: %v", err)
89 }
90 }
91 forkPath := c.User.Username + "/" + name
92 return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
93 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
94 })
95}
96
97func runRequireSigned(c *Ctx, args []string) int {
98 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
99 return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
100 }
101 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
102 if code >= 0 {
103 return code
104 }
105 s := repo.Settings
106 s.RequireSignedCommits = args[1] == "on"
107 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
108 return c.fail(protocol.ExitFailure, "%v", err)
109 }
110 return c.emit(s, func(w io.Writer) {
111 fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
112 })
113}
114
115// mrRef parses "<owner/name> <n>" and loads the MR.
116func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
117 if len(args) < 2 {
118 return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
119 }
120 repo, code := resolveRepo(c, args[0], perm)
121 if code >= 0 {
122 return repo, store.MR{}, code
123 }
124 n, err := strconv.ParseInt(args[1], 10, 64)
125 if err != nil {
126 return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
127 }
128 mr, err := c.Store.MRByNumber(repo.ID, n)
129 if errors.Is(err, store.ErrNotFound) {
130 return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
131 }
132 if err != nil {
133 return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
134 }
135 return repo, mr, -1
136}
137
138func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
139
140func runMRCreate(c *Ctx, args []string) int {
141 var path, source, target, title, body, file string
142 for i := 0; i < len(args); i++ {
143 switch args[i] {
144 case "--source", "--target", "--title", "--body", "--file":
145 if i+1 >= len(args) {
146 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
147 }
148 v := args[i+1]
149 switch args[i] {
150 case "--source":
151 source = v
152 case "--target":
153 target = v
154 case "--title":
155 title = v
156 case "--body":
157 body = v
158 case "--file":
159 file = v
160 }
161 i++
162 default:
163 if path != "" {
164 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
165 }
166 path = args[i]
167 }
168 }
169 if path == "" || source == "" || title == "" {
170 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
171 }
172 repo, code := resolveRepo(c, path, policy.CanRead)
173 if code >= 0 {
174 return code
175 }
176 if target == "" {
177 target = repo.DefaultBranch
178 }
179
180 // Source is "branch" (same repo) or "owner/name:branch" (a fork).
181 srcRepo := repo
182 srcBranch := source
183 if sp, br, ok := strings.Cut(source, ":"); ok {
184 srcBranch = br
185 var scode int
186 srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
187 if scode >= 0 {
188 return scode
189 }
190 if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
191 return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
192 }
193 }
194 srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
195 headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
196 if err != nil {
197 return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
198 }
199 b, err := bodyFrom(c, body, file)
200 if err != nil {
201 return c.fail(protocol.ExitUsage, "%v", err)
202 }
203 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA)
204 if err != nil {
205 return c.fail(protocol.ExitFailure, "%v", err)
206 }
207 // Fetch the head into the target so the target owns the objects.
208 dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
209 if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
210 return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
211 }
212 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
213 return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) {
214 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
215 })
216}
217
218type mrOut struct {
219 Number int64 `json:"number"`
220 Title string `json:"title"`
221 State string `json:"state"`
222 Author string `json:"author"`
223 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
224 TargetRef string `json:"target_ref"`
225 HeadSHA string `json:"head_sha"`
226 Body string `json:"body,omitempty"`
227 CreatedAt string `json:"created_at"`
228}
229
230func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
231 src := ""
232 if m.SourcePath != "" {
233 if m.SourceRepoID == repo.ID {
234 src = m.SourceRef
235 } else {
236 src = m.SourcePath + ":" + m.SourceRef
237 }
238 }
239 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
240 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt}
241 if withBody {
242 o.Body = m.Body
243 }
244 return o
245}
246
247func runMRList(c *Ctx, args []string) int {
248 state := "open"
249 var path string
250 for i := 0; i < len(args); i++ {
251 switch args[i] {
252 case "--state":
253 if i+1 >= len(args) {
254 return c.fail(protocol.ExitUsage, "--state requires a value")
255 }
256 state = args[i+1]
257 i++
258 default:
259 if path != "" {
260 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
261 }
262 path = args[i]
263 }
264 }
265 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
266 if path == "" || !valid[state] {
267 return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all]")
268 }
269 repo, code := resolveRepo(c, path, policy.CanRead)
270 if code >= 0 {
271 return code
272 }
273 mrs, err := c.Store.ListMRs(repo.ID, state)
274 if err != nil {
275 return c.fail(protocol.ExitFailure, "%v", err)
276 }
277 var ds []mrOut
278 for _, m := range mrs {
279 ds = append(ds, mrToOut(repo, m, false))
280 }
281 return c.emit(ds, func(w io.Writer) {
282 for _, d := range ds {
283 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
284 }
285 })
286}
287
288func runMRShow(c *Ctx, args []string) int {
289 repo, mr, code := mrRef(c, args, policy.CanRead)
290 if code >= 0 {
291 return code
292 }
293 if len(args) != 2 {
294 return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
295 }
296 comments, err := c.Store.ListMRComments(mr.ID)
297 if err != nil {
298 return c.fail(protocol.ExitFailure, "%v", err)
299 }
300 reviews, err := c.Store.ListMRReviews(mr.ID)
301 if err != nil {
302 return c.fail(protocol.ExitFailure, "%v", err)
303 }
304 type commentOut struct {
305 Author string `json:"author"`
306 Body string `json:"body"`
307 CreatedAt string `json:"created_at"`
308 }
309 type reviewOut struct {
310 Reviewer string `json:"reviewer"`
311 Verdict string `json:"verdict"`
312 Stale bool `json:"stale"`
313 }
314 var cs []commentOut
315 for _, cm := range comments {
316 cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
317 }
318 var rs []reviewOut
319 for _, r := range reviews {
320 rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale})
321 }
322 d := struct {
323 mrOut
324 Comments []commentOut `json:"comments,omitempty"`
325 Reviews []reviewOut `json:"reviews,omitempty"`
326 }{mrToOut(repo, mr, true), cs, rs}
327 return c.emit(d, func(w io.Writer) {
328 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
329 if d.Body != "" {
330 fmt.Fprintf(w, "\n%s\n", d.Body)
331 }
332 for _, r := range rs {
333 stale := ""
334 if r.Stale {
335 stale = " (stale)"
336 }
337 fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale)
338 }
339 for _, cm := range cs {
340 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
341 }
342 })
343}
344
345func runMRDiff(c *Ctx, args []string) int {
346 repo, mr, code := mrRef(c, args, policy.CanRead)
347 if code >= 0 {
348 return code
349 }
350 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
351 head := mrHeadRef(mr.Number)
352 base, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
353 if err != nil {
354 return c.fail(protocol.ExitFailure, "%v", err)
355 }
356 patch, err := gitutil.Diff(dir, base, head, 4<<20)
357 if err != nil {
358 return c.fail(protocol.ExitFailure, "%v", err)
359 }
360 fmt.Fprint(c.Stdout, patch)
361 return protocol.ExitOK
362}
363
364func runMRComment(c *Ctx, args []string) int {
365 var rest []string
366 var message, file string
367 for i := 0; i < len(args); i++ {
368 switch args[i] {
369 case "--message", "--file":
370 if i+1 >= len(args) {
371 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
372 }
373 if args[i] == "--message" {
374 message = args[i+1]
375 } else {
376 file = args[i+1]
377 }
378 i++
379 default:
380 rest = append(rest, args[i])
381 }
382 }
383 repo, mr, code := mrRef(c, rest, policy.CanRead)
384 if code >= 0 {
385 return code
386 }
387 body, err := bodyFrom(c, message, file)
388 if err != nil {
389 return c.fail(protocol.ExitUsage, "%v", err)
390 }
391 if strings.TrimSpace(body) == "" {
392 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
393 }
394 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
395 return c.fail(protocol.ExitFailure, "%v", err)
396 }
397 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
398 fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
399 })
400}
401
402func runMRReview(c *Ctx, args []string) int {
403 verdict := ""
404 var rest []string
405 for _, a := range args {
406 switch a {
407 case "--approve":
408 verdict = "approve"
409 case "--request-changes":
410 verdict = "request_changes"
411 case "--comment":
412 verdict = "comment"
413 default:
414 rest = append(rest, a)
415 }
416 }
417 if verdict == "" {
418 return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
419 }
420 repo, mr, code := mrRef(c, rest, policy.CanRead)
421 if code >= 0 {
422 return code
423 }
424 if mr.State != "open" {
425 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
426 }
427 if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
428 return c.fail(protocol.ExitFailure, "%v", err)
429 }
430 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
431 fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
432 })
433}
434
435func runMRMerge(c *Ctx, args []string) int {
436 strategy := ""
437 var rest []string
438 for i := 0; i < len(args); i++ {
439 if args[i] == "--strategy" {
440 if i+1 >= len(args) {
441 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge")
442 }
443 strategy = args[i+1]
444 i++
445 continue
446 }
447 rest = append(rest, args[i])
448 }
449 if strategy != "" && strategy != "ff" && strategy != "merge" {
450 return c.fail(protocol.ExitUsage, "--strategy must be ff or merge")
451 }
452 repo, mr, code := mrRef(c, rest, policy.CanWrite)
453 if code >= 0 {
454 return code
455 }
456 if mr.State != "open" && mr.State != "source_gone" {
457 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
458 }
459
460 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
461 targetRef := "refs/heads/" + mr.TargetRef
462 targetSHA, err := gitutil.ResolveRef(dir, targetRef)
463 if err != nil {
464 return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
465 }
466 headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
467 if err != nil {
468 return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
469 }
470
471 upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
472 if err != nil {
473 return c.fail(protocol.ExitFailure, "%v", err)
474 }
475 if upToDate {
476 return c.fail(protocol.ExitUsage, "target already contains the MR head")
477 }
478 ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
479 if err != nil {
480 return c.fail(protocol.ExitFailure, "%v", err)
481 }
482
483 // Signature policy matrix: with require_signed_commits, only
484 // fast-forward is allowed — a server-created merge commit would be
485 // unsigned, violating the branch's own policy — and every landed
486 // commit must be verified.
487 if repo.Settings.RequireSignedCommits {
488 if strategy == "merge" || !ffPossible {
489 return c.fail(protocol.ExitDenied,
490 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
491 repo.Path(), mr.SourceRef, mr.TargetRef)
492 }
493 strategy = "ff"
494 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
495 if err != nil {
496 return c.fail(protocol.ExitFailure, "%v", err)
497 }
498 for _, sha := range commits {
499 raw, err := gitutil.ReadCommit(dir, sha)
500 if err != nil {
501 return c.fail(protocol.ExitFailure, "%v", err)
502 }
503 parsed, err := sigParse(raw)
504 if err != nil {
505 return c.fail(protocol.ExitFailure, "%v", err)
506 }
507 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
508 if err != nil {
509 return c.fail(protocol.ExitFailure, "%v", err)
510 }
511 if res.State != "verified" {
512 return c.fail(protocol.ExitDenied,
513 "%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
514 }
515 }
516 }
517 if strategy == "" {
518 if ffPossible {
519 strategy = "ff"
520 } else {
521 strategy = "merge"
522 }
523 }
524
525 var newSHA string
526 switch strategy {
527 case "ff":
528 if !ffPossible {
529 return c.fail(protocol.ExitUsage,
530 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
531 }
532 newSHA = headSHA
533 case "merge":
534 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
535 if err != nil {
536 return c.fail(protocol.ExitFailure, "%v", err)
537 }
538 if email == "" {
539 return c.fail(protocol.ExitDenied,
540 "merge commits carry your identity: verify a primary email first (ask an admin, or use a fast-forward merge)")
541 }
542 tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
543 if err != nil {
544 return c.fail(protocol.ExitFailure, "%v", err)
545 }
546 if conflict {
547 return c.fail(protocol.ExitUsage,
548 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
549 }
550 msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
551 newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, email, msg)
552 if err != nil {
553 return c.fail(protocol.ExitFailure, "%v", err)
554 }
555 }
556
557 // CAS so a concurrent push between our read and this write fails the
558 // merge instead of silently discarding the push.
559 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
560 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
561 }
562 if err := c.Store.SetMRState(mr.ID, "merged"); err != nil {
563 return c.fail(protocol.ExitFailure, "%v", err)
564 }
565 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
566 return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
567 fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
568 })
569}
570
571func runMRClose(c *Ctx, args []string) int {
572 repo, mr, code := mrRef(c, args, policy.CanRead)
573 if code >= 0 {
574 return code
575 }
576 if len(args) != 2 {
577 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
578 }
579 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
580 if err != nil {
581 return c.fail(protocol.ExitFailure, "%v", err)
582 }
583 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
584 return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
585 }
586 if mr.State == "merged" || mr.State == "closed" {
587 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
588 }
589 if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
590 return c.fail(protocol.ExitFailure, "%v", err)
591 }
592 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
593 fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
594 })
595}
internal/control/repo.go +5
@@ -188,6 +188,11 @@ func runRepoDelete(c *Ctx, args []string) int {
188188 if !yes {
189189 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
190190 }
191 // Open MRs sourced from this repo keep working (targets own the
192 // objects) but must show that the source is gone.
193 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
191196 if err := c.Store.DeleteRepo(repo.ID); err != nil {
192197 return c.fail(protocol.ExitFailure, "%v", err)
193198 }
internal/control/sig.go +3
@@ -92,6 +92,9 @@ func runPGPRemove(c *Ctx, args []string) int {
9292 })
9393}
9494
95// sigParse is a package-local alias so callers avoid importing sig directly.
96func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
97
9598// VerifyCommitCached verifies one commit with the epoch cache. Shared with
9699// the web UI.
97100func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
internal/gitutil/merge.go added +117
@@ -0,0 +1,117 @@
1package gitutil
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "strings"
8)
9
10// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
11// update. Objects are copied, not shared — the destination owns everything
12// afterward, which is what keeps MRs alive when their fork is deleted.
13func FetchInto(dstDir, srcDir, srcRef, dstRef string) error {
14 cmd := exec.Command("git", "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head",
15 srcDir, "+"+srcRef+":"+dstRef)
16 if out, err := cmd.CombinedOutput(); err != nil {
17 return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out)
18 }
19 return nil
20}
21
22// UpdateRefCAS points ref at newSHA only if it currently points at oldSHA
23// (empty oldSHA = must not exist). This is the compare-and-swap that makes
24// merges safe against concurrent pushes.
25func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
26 args := []string{"-C", dir, "update-ref", ref, newSHA}
27 if oldSHA != "" {
28 args = append(args, oldSHA)
29 }
30 cmd := exec.Command("git", args...)
31 if out, err := cmd.CombinedOutput(); err != nil {
32 return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
33 }
34 return nil
35}
36
37func DeleteRef(dir, ref string) error {
38 cmd := exec.Command("git", "-C", dir, "update-ref", "-d", ref)
39 if out, err := cmd.CombinedOutput(); err != nil {
40 return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
41 }
42 return nil
43}
44
45// RevListRange returns commits in old..new, newest first.
46func RevListRange(dir, old, new string) ([]string, error) {
47 cmd := exec.Command("git", "-C", dir, "rev-list", new, "^"+old)
48 out, err := cmd.Output()
49 if err != nil {
50 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
51 }
52 var shas []string
53 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
54 if l != "" {
55 shas = append(shas, l)
56 }
57 }
58 return shas, nil
59}
60
61// MergeTree performs a real merge of ours and theirs, returning the merged
62// tree id. conflict=true means the merge cannot be done automatically.
63func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
64 cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", ours, theirs)
65 out, runErr := cmd.Output()
66 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
67 if runErr != nil {
68 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
69 return "", true, nil // conflicted merge
70 }
71 return "", false, fmt.Errorf("merge-tree: %w", runErr)
72 }
73 return tree, false, nil
74}
75
76// CommitTree creates a merge commit with the given parents, authored and
77// committed by the merging user. There is no server signing key by design.
78func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) {
79 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
80 for _, p := range parents {
81 args = append(args, "-p", p)
82 }
83 cmd := exec.Command("git", args...)
84 cmd.Env = append(os.Environ(),
85 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
86 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
87 )
88 out, err := cmd.Output()
89 if err != nil {
90 return "", fmt.Errorf("commit-tree: %w", err)
91 }
92 return strings.TrimSpace(string(out)), nil
93}
94
95// Diff returns the patch for old..new (three-dot semantics are the caller's
96// job: pass the merge base as old).
97func Diff(dir, old, new string, limit int64) (string, error) {
98 cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", old, new)
99 out, err := cmd.Output()
100 if err != nil {
101 return "", fmt.Errorf("diff: %w", err)
102 }
103 if int64(len(out)) > limit {
104 out = out[:limit]
105 }
106 return string(out), nil
107}
108
109// MergeBase returns the best common ancestor, or an error if none exists.
110func MergeBase(dir, a, b string) (string, error) {
111 cmd := exec.Command("git", "-C", dir, "merge-base", a, b)
112 out, err := cmd.Output()
113 if err != nil {
114 return "", fmt.Errorf("no common history between %s and %s", a, b)
115 }
116 return strings.TrimSpace(string(out)), nil
117}
internal/hookd/hookd.go +154 −28
@@ -1,18 +1,27 @@
11// Package hookd is the unix-socket bridge between git hooks and the daemon.
22// The hook process (forged in hook mode) computes git facts — it inherits
33// git's quarantine environment, which the daemon does not see — and sends
4// them here; the daemon answers with a pure policy decision.
4// them here; the daemon answers with a policy decision.
5//
6// pre-receive is two-phase when the repo requires signed commits: the first
7// response sets NeedCommits, and the hook answers with the raw commit
8// objects (only the hook can read them out of quarantine) for verification.
59package hookd
610
711import (
812 "crypto/sha256"
913 "encoding/json"
1014 "fmt"
15 "log/slog"
1116 "net"
1217 "os"
1318 "path/filepath"
1419
20 "github.com/krazywarez/forge/internal/config"
21 "github.com/krazywarez/forge/internal/control"
22 "github.com/krazywarez/forge/internal/gitutil"
1523 "github.com/krazywarez/forge/internal/policy"
24 "github.com/krazywarez/forge/internal/sig"
1625 "github.com/krazywarez/forge/internal/store"
1726)
1827
@@ -31,9 +40,20 @@ type Request struct {
3140 Updates []policy.RefUpdate `json:"updates"`
3241}
3342
43type RawCommit struct {
44 SHA string `json:"sha"`
45 Raw []byte `json:"raw"`
46}
47
48// CommitsPayload is the hook's second message when NeedCommits was set.
49type CommitsPayload struct {
50 Commits []RawCommit `json:"commits"`
51}
52
3453type Response struct {
35 Allow bool `json:"allow"`
36 Message string `json:"message,omitempty"`
54 Allow bool `json:"allow"`
55 Message string `json:"message,omitempty"`
56 NeedCommits bool `json:"need_commits,omitempty"`
3757}
3858
3959// SocketPath returns the hook socket location. It prefers the server root,
@@ -51,18 +71,19 @@ func SocketPath(root string) string {
5171}
5272
5373type Server struct {
54 st *store.Store
74 cfg config.Config
75 st *store.Store
5576}
5677
5778// Serve listens on the unix socket until the listener is closed.
58func Serve(root string, st *store.Store) (func() error, error) {
59 path := SocketPath(root)
79func Serve(cfg config.Config, st *store.Store) (func() error, error) {
80 path := SocketPath(cfg.Server.Root)
6081 os.Remove(path)
6182 ln, err := net.Listen("unix", path)
6283 if err != nil {
6384 return nil, err
6485 }
65 s := &Server{st: st}
86 s := &Server{cfg: cfg, st: st}
6687 go func() {
6788 for {
6889 conn, err := ln.Accept()
@@ -77,48 +98,153 @@ func Serve(root string, st *store.Store) (func() error, error) {
7798
7899func (s *Server) handle(conn net.Conn) {
79100 defer conn.Close()
101 dec := json.NewDecoder(conn)
102 enc := json.NewEncoder(conn)
80103 var req Request
81 if err := json.NewDecoder(conn).Decode(&req); err != nil {
82 json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"})
104 if err := dec.Decode(&req); err != nil {
105 enc.Encode(Response{Allow: false, Message: "bad hook request"})
83106 return
84107 }
85 json.NewEncoder(conn).Encode(s.decide(req))
86}
87
88func (s *Server) decide(req Request) Response {
89108 switch req.Hook {
90109 case "pre-receive":
91 repo, err := s.st.RepoByID(req.RepoID)
110 s.preReceive(req, dec, enc)
111 case "post-receive":
112 s.postReceive(req)
113 enc.Encode(Response{Allow: true})
114 default:
115 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
116 }
117}
118
119func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
120 repo, err := s.st.RepoByID(req.RepoID)
121 if err != nil {
122 enc.Encode(Response{Allow: false, Message: "unknown repository"})
123 return
124 }
125 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
126 enc.Encode(Response{Allow: false, Message: msg})
127 return
128 }
129 if !repo.Settings.RequireSignedCommits {
130 enc.Encode(Response{Allow: true})
131 return
132 }
133
134 // Phase two: ask the hook for the incoming commit objects.
135 if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
136 return
137 }
138 var payload CommitsPayload
139 if err := dec.Decode(&payload); err != nil {
140 enc.Encode(Response{Allow: false, Message: "bad commits payload"})
141 return
142 }
143 db := store.SigDB{Store: s.st}
144 for _, rc := range payload.Commits {
145 parsed, err := sig.ParseCommit(rc.Raw)
92146 if err != nil {
93 return Response{Allow: false, Message: "unknown repository"}
147 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unparseable commit %s", rc.SHA)})
148 return
94149 }
95 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
96 return Response{Allow: false, Message: msg}
150 res, err := sig.VerifyCommit(db, parsed)
151 if err != nil || res.State != sig.Verified {
152 state := "error"
153 if err == nil {
154 state = string(res.State)
155 }
156 enc.Encode(Response{Allow: false, Message: fmt.Sprintf(
157 "this repository requires signed commits: %.10s is %s", rc.SHA, state)})
158 return
97159 }
98 return Response{Allow: true}
99 case "post-receive":
100 // Event recording and signature verification enqueue land in M4.
101 return Response{Allow: true}
102 default:
103 return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}
104160 }
161 enc.Encode(Response{Allow: true})
105162}
106163
107// Ask sends one request from the hook process to the daemon.
108func Ask(socketPath string, req Request) (Response, error) {
164// postReceive applies the cross-repo MR effect: a push to a source branch
165// refreshes refs/merge-requests/N/head in every target repo, by fetching —
166// the target owns the objects, so the MR outlives the fork. This is the only
167// place a hook writes outside its own repository.
168func (s *Server) postReceive(req Request) {
169 for _, u := range req.Updates {
170 branch, ok := cutHeads(u.Ref)
171 if !ok {
172 continue
173 }
174 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
175 if err != nil {
176 slog.Error("post-receive: listing MRs", "err", err)
177 continue
178 }
179 srcRepo, err := s.st.RepoByID(req.RepoID)
180 if err != nil {
181 continue
182 }
183 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
184 for _, mr := range mrs {
185 target, err := s.st.RepoByID(mr.RepoID)
186 if err != nil {
187 continue
188 }
189 if u.IsDelete {
190 if mr.State == "open" {
191 s.st.SetMRState(mr.ID, "source_gone")
192 }
193 continue // head ref retained: the diff stays viewable
194 }
195 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
196 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
197 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
198 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
199 continue
200 }
201 if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
202 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
203 }
204 if mr.State == "source_gone" {
205 s.st.SetMRState(mr.ID, "open") // branch came back
206 }
207 }
208 }
209}
210
211func cutHeads(ref string) (string, bool) {
212 const p = "refs/heads/"
213 if len(ref) > len(p) && ref[:len(p)] == p {
214 return ref[len(p):], true
215 }
216 return "", false
217}
218
219// Ask sends one request from the hook process to the daemon. commits is
220// called if the daemon asks for the incoming commit objects.
221func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) {
109222 conn, err := net.Dial("unix", socketPath)
110223 if err != nil {
111224 return Response{}, err
112225 }
113226 defer conn.Close()
114 if err := json.NewEncoder(conn).Encode(req); err != nil {
227 enc := json.NewEncoder(conn)
228 dec := json.NewDecoder(conn)
229 if err := enc.Encode(req); err != nil {
115230 return Response{}, err
116231 }
117232 var resp Response
118 if err := json.NewDecoder(conn).Decode(&resp); err != nil {
233 if err := dec.Decode(&resp); err != nil {
234 return Response{}, err
235 }
236 if !resp.NeedCommits {
237 return resp, nil
238 }
239 payload, err := commits()
240 if err != nil {
241 return Response{}, err
242 }
243 if err := enc.Encode(payload); err != nil {
119244 return Response{}, err
120245 }
121 return resp, nil
246 err = dec.Decode(&resp)
247 return resp, err
122248}
123249
124250// WriteHookScripts (re)generates the shared hooks directory. Called at
internal/httpd/routes.go +2
@@ -39,6 +39,8 @@ func (s *Server) Routes() []Route {
3939 Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive},
4040 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues},
4141 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/{n}", Handler: s.issue},
42 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs", Handler: s.mrs},
43 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
4244 )
4345
4446 // Account-mode routes (login, web edits) are appended here in M8 —
internal/httpd/web.go +84 −19
@@ -244,6 +244,30 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
244244 w.Write(data)
245245}
246246
247type diffLine struct {
248 Class string
249 Text string
250}
251
252func classifyDiff(patch string) []diffLine {
253 var lines []diffLine
254 for _, l := range strings.Split(patch, "\n") {
255 class := ""
256 switch {
257 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
258 class = "meta"
259 case strings.HasPrefix(l, "@@"):
260 class = "hunk"
261 case strings.HasPrefix(l, "+"):
262 class = "add"
263 case strings.HasPrefix(l, "-"):
264 class = "del"
265 }
266 lines = append(lines, diffLine{class, l})
267 }
268 return lines
269}
270
247271type sigView struct {
248272 State string
249273 Signer string
@@ -329,25 +353,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
329353 return
330354 }
331355 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
332 type diffLine struct {
333 Class string
334 Text string
335 }
336 var lines []diffLine
337 for _, l := range strings.Split(patch, "\n") {
338 class := ""
339 switch {
340 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
341 class = "meta"
342 case strings.HasPrefix(l, "@@"):
343 class = "hunk"
344 case strings.HasPrefix(l, "+"):
345 class = "add"
346 case strings.HasPrefix(l, "-"):
347 class = "del"
348 }
349 lines = append(lines, diffLine{class, l})
350 }
356 lines := classifyDiff(patch)
351357 committerEmail := ""
352358 if parsed.CommitterEmail != parsed.AuthorEmail {
353359 committerEmail = parsed.CommitterEmail
@@ -413,6 +419,65 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
413419 }{p, iss, comments})
414420}
415421
422func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
423 p, ok := s.repoFor(w, r, "")
424 if !ok {
425 return
426 }
427 state := r.URL.Query().Get("state")
428 if state == "" {
429 state = "open"
430 }
431 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
432 if !valid[state] {
433 state = "open"
434 }
435 mrs, err := s.st.ListMRs(p.Repo.ID, state)
436 if err != nil {
437 http.Error(w, "internal error", http.StatusInternalServerError)
438 return
439 }
440 s.render(w, "mrs.html", struct {
441 repoPage
442 State string
443 MRs []store.MR
444 }{p, state, mrs})
445}
446
447func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
448 p, ok := s.repoFor(w, r, "")
449 if !ok {
450 return
451 }
452 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
453 if err != nil {
454 http.NotFound(w, r)
455 return
456 }
457 m, err := s.st.MRByNumber(p.Repo.ID, n)
458 if err != nil {
459 http.NotFound(w, r)
460 return
461 }
462 comments, _ := s.st.ListMRComments(m.ID)
463 reviews, _ := s.st.ListMRReviews(m.ID)
464
465 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
466 var lines []diffLine
467 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
468 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
469 lines = classifyDiff(patch)
470 }
471 }
472 s.render(w, "mr.html", struct {
473 repoPage
474 MR store.MR
475 Comments []store.IssueComment
476 Reviews []store.MRReview
477 DiffLines []diffLine
478 }{p, m, comments, reviews, lines})
479}
480
416481func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
417482 p, ok := s.repoFor(w, r, "")
418483 if !ok {
internal/store/mrs.go added +233
@@ -0,0 +1,233 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8type MR struct {
9 ID int64
10 RepoID int64
11 Number int64
12 Author string
13 SourceRepoID int64 // 0 when the source repo is gone
14 SourcePath string // owner/name of source repo, "" when gone
15 SourceRef string
16 TargetRef string
17 Title string
18 Body string
19 State string // open | merged | closed | source_gone
20 HeadSHA string
21 CreatedAt string
22 UpdatedAt string
23}
24
25type MRReview struct {
26 Reviewer string
27 Verdict string
28 HeadSHA string
29 Stale bool
30 CreatedAt string
31}
32
33func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA string) (int64, error) {
34 tx, err := s.DB.Begin()
35 if err != nil {
36 return 0, err
37 }
38 defer tx.Rollback()
39 if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil {
40 return 0, err
41 }
42 var n int64
43 if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
44 return 0, err
45 }
46 if _, err := tx.Exec(`
47 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha)
48 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
49 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA); err != nil {
50 return 0, err
51 }
52 return n, tx.Commit()
53}
54
55const mrSelect = `
56 SELECT m.id, m.repo_id, m.number, u.username,
57 COALESCE(m.source_repo_id, 0),
58 COALESCE(su.username || '/' || sr.name, ''),
59 m.source_ref, m.target_ref, m.title, m.body, m.state, m.head_sha,
60 m.created_at, m.updated_at
61 FROM merge_requests m
62 JOIN users u ON u.id = m.author_id
63 LEFT JOIN repos sr ON sr.id = m.source_repo_id
64 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id`
65
66func scanMR(row interface{ Scan(...any) error }) (MR, error) {
67 var m MR
68 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
69 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.CreatedAt, &m.UpdatedAt)
70 return m, err
71}
72
73func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
74 m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number))
75 if errors.Is(err, sql.ErrNoRows) {
76 return m, ErrNotFound
77 }
78 return m, err
79}
80
81func (s *Store) ListMRs(repoID int64, state string) ([]MR, error) {
82 q := mrSelect + " WHERE m.repo_id = ?"
83 args := []any{repoID}
84 if state != "all" {
85 q += " AND m.state = ?"
86 args = append(args, state)
87 }
88 q += " ORDER BY m.number DESC"
89 rows, err := s.DB.Query(q, args...)
90 if err != nil {
91 return nil, err
92 }
93 defer rows.Close()
94 var out []MR
95 for rows.Next() {
96 m, err := scanMR(rows)
97 if err != nil {
98 return nil, err
99 }
100 out = append(out, m)
101 }
102 return out, rows.Err()
103}
104
105// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
106// repo branch — the cross-repo hook effect consults this.
107func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
108 rows, err := s.DB.Query(
109 mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
110 sourceRepoID, sourceRef)
111 if err != nil {
112 return nil, err
113 }
114 defer rows.Close()
115 var out []MR
116 for rows.Next() {
117 m, err := scanMR(rows)
118 if err != nil {
119 return nil, err
120 }
121 out = append(out, m)
122 }
123 return out, rows.Err()
124}
125
126func (s *Store) SetMRState(mrID int64, state string) error {
127 res, err := s.DB.Exec(
128 "UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
129 state, mrID)
130 if err != nil {
131 return err
132 }
133 if n, _ := res.RowsAffected(); n == 0 {
134 return ErrNotFound
135 }
136 return nil
137}
138
139// UpdateMRHead records a new head and marks every review at another head
140// stale, in one transaction.
141func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
142 tx, err := s.DB.Begin()
143 if err != nil {
144 return err
145 }
146 defer tx.Rollback()
147 if _, err := tx.Exec(
148 "UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
149 headSHA, mrID); err != nil {
150 return err
151 }
152 if _, err := tx.Exec(
153 "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
154 return err
155 }
156 return tx.Commit()
157}
158
159// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
160// when a fork is deleted. Head refs in the target repos are retained.
161func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
162 _, err := s.DB.Exec(
163 "UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
164 sourceRepoID)
165 return err
166}
167
168func (s *Store) AddMRComment(mrID, authorID int64, body string) error {
169 _, err := s.DB.Exec(
170 "INSERT INTO mr_comments (mr_id, author_id, body) VALUES (?, ?, ?)", mrID, authorID, body)
171 return err
172}
173
174func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
175 rows, err := s.DB.Query(`
176 SELECT u.username, c.body, c.created_at
177 FROM mr_comments c JOIN users u ON u.id = c.author_id
178 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
179 if err != nil {
180 return nil, err
181 }
182 defer rows.Close()
183 var out []IssueComment
184 for rows.Next() {
185 var c IssueComment
186 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil {
187 return nil, err
188 }
189 out = append(out, c)
190 }
191 return out, rows.Err()
192}
193
194func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
195 _, err := s.DB.Exec(
196 "INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
197 mrID, reviewerID, verdict, headSHA)
198 return err
199}
200
201func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
202 rows, err := s.DB.Query(`
203 SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
204 FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
205 WHERE r.mr_id = ? ORDER BY r.id`, mrID)
206 if err != nil {
207 return nil, err
208 }
209 defer rows.Close()
210 var out []MRReview
211 for rows.Next() {
212 var r MRReview
213 var stale int
214 if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
215 return nil, err
216 }
217 r.Stale = stale != 0
218 out = append(out, r)
219 }
220 return out, rows.Err()
221}
222
223// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
224func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
225 var addr string
226 err := s.DB.QueryRow(
227 "SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
228 userID).Scan(&addr)
229 if errors.Is(err, sql.ErrNoRows) {
230 return "", nil
231 }
232 return addr, err
233}
internal/store/repos.go +10 −4
@@ -16,6 +16,7 @@ type Repo struct {
1616 Name string
1717 Visibility string // public | private
1818 DefaultBranch string
19 ForkOf int64 // 0 when not a fork
1920 Settings RepoSettings
2021}
2122
@@ -50,10 +51,10 @@ func (s *Store) RepoByPath(path string) (Repo, error) {
5051 var r Repo
5152 var settingsJSON string
5253 err := s.DB.QueryRow(`
53 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
54 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
5455 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
5556 WHERE u.username = ? AND r.name = ?`, owner, name).
56 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
57 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
5758 if errors.Is(err, sql.ErrNoRows) {
5859 return Repo{}, ErrNotFound
5960 }
@@ -75,6 +76,11 @@ func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
7576 return err
7677}
7778
79func (s *Store) SetForkOf(repoID, parentID int64) error {
80 _, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
81 return err
82}
83
7884func (s *Store) DeleteRepo(repoID int64) error {
7985 res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
8086 if err != nil {
@@ -176,10 +182,10 @@ func (s *Store) RepoByID(id int64) (Repo, error) {
176182 var r Repo
177183 var settingsJSON string
178184 err := s.DB.QueryRow(`
179 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
185 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
180186 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
181187 WHERE r.id = ?`, id).
182 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
188 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
183189 if errors.Is(err, sql.ErrNoRows) {
184190 return Repo{}, ErrNotFound
185191 }
internal/web/templates/mr.html added +15
@@ -0,0 +1,15 @@
1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2>
5<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}}
6 @ <code>{{.MR.HeadSHA}}</code></p>
7{{if .MR.Body}}<pre class="message">{{.MR.Body}}</pre>{{end}}
8{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}}
9{{range .Comments}}
10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
11{{end}}
12<h3>diff</h3>
13<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
14{{end}}</pre>
15{{end}}
internal/web/templates/mrs.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<h2>merge requests ({{.State}})</h2>
5<p class="crumbs"><a href="?state=open">open</a> · <a href="?state=merged">merged</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p>
6<table>
7{{range .MRs}}<tr>
8 <td>!{{.Number}}</td>
9 <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></td>
10 <td>{{.State}}</td>
11 <td>{{.Author}}</td>
12 <td>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</td>
13</tr>
14{{else}}<tr><td>no merge requests</td></tr>{{end}}
15</table>
16{{end}}