Commit 08b325bb1d
Verified · cmc
Layout: unified · split
cmd/forged/hook.go +33
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "bufio" |
| 5 | 5 | "fmt" |
| 6 | 6 | "os" |
| 7 | "os/exec" | |
| 7 | 8 | "strconv" |
| 8 | 9 | "strings" |
| 9 | 10 | |
| @@ -14,6 +15,36 @@ import ( | ||
| 14 | 15 | "github.com/krazywarez/forge/internal/policy" |
| 15 | 16 | ) |
| 16 | 17 | |
| 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. | |
| 21 | func 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 | ||
| 17 | 48 | // hookCmd runs inside a git hook. It computes git facts here — the hook |
| 18 | 49 | // process inherits git's quarantine environment, so incoming objects are |
| 19 | 50 | // visible — and asks the daemon for a policy decision over the unix socket. |
| @@ -57,6 +88,8 @@ func hookCmd() *cobra.Command { | ||
| 57 | 88 | RepoID: repoID, |
| 58 | 89 | UserID: userID, |
| 59 | 90 | Updates: updates, |
| 91 | }, func() (hookd.CommitsPayload, error) { | |
| 92 | return collectIncomingCommits(updates) | |
| 60 | 93 | }) |
| 61 | 94 | if err != nil { |
| 62 | 95 | return fmt.Errorf("forge daemon unreachable: %w", err) |
cmd/forged/main.go +1 −1
| @@ -112,7 +112,7 @@ func serveCmd() *cobra.Command { | ||
| 112 | 112 | if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil { |
| 113 | 113 | return err |
| 114 | 114 | } |
| 115 | stopHookd, err := hookd.Serve(cfg.Server.Root, st) | |
| 115 | stopHookd, err := hookd.Serve(cfg, st) | |
| 116 | 116 | if err != nil { |
| 117 | 117 | return err |
| 118 | 118 | } |
e2e/mr_test.go added +260
| @@ -0,0 +1,260 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 15 | type 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 | ||
| 27 | func (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 | ||
| 42 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 16 | func 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 | ||
| 41 | func 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 | ||
| 97 | func 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. | |
| 116 | func 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 | ||
| 138 | func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } | |
| 139 | ||
| 140 | func 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 | ||
| 218 | type 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 | ||
| 230 | func 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 | ||
| 247 | func 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 | ||
| 288 | func 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 | ||
| 345 | func 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 | ||
| 364 | func 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 | ||
| 402 | func 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 | ||
| 435 | func 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 | ||
| 571 | func 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 { | ||
| 188 | 188 | if !yes { |
| 189 | 189 | return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") |
| 190 | 190 | } |
| 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 | } | |
| 191 | 196 | if err := c.Store.DeleteRepo(repo.ID); err != nil { |
| 192 | 197 | return c.fail(protocol.ExitFailure, "%v", err) |
| 193 | 198 | } |
internal/control/sig.go +3
| @@ -92,6 +92,9 @@ func runPGPRemove(c *Ctx, args []string) int { | ||
| 92 | 92 | }) |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | // sigParse is a package-local alias so callers avoid importing sig directly. | |
| 96 | func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) } | |
| 97 | ||
| 95 | 98 | // VerifyCommitCached verifies one commit with the epoch cache. Shared with |
| 96 | 99 | // the web UI. |
| 97 | 100 | func 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 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | func 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. | |
| 25 | func 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 | ||
| 37 | func 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. | |
| 46 | func 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. | |
| 63 | func 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. | |
| 78 | func 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). | |
| 97 | func 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. | |
| 110 | func 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 @@ | ||
| 1 | 1 | // Package hookd is the unix-socket bridge between git hooks and the daemon. |
| 2 | 2 | // The hook process (forged in hook mode) computes git facts — it inherits |
| 3 | 3 | // 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. | |
| 5 | 9 | package hookd |
| 6 | 10 | |
| 7 | 11 | import ( |
| 8 | 12 | "crypto/sha256" |
| 9 | 13 | "encoding/json" |
| 10 | 14 | "fmt" |
| 15 | "log/slog" | |
| 11 | 16 | "net" |
| 12 | 17 | "os" |
| 13 | 18 | "path/filepath" |
| 14 | 19 | |
| 20 | "github.com/krazywarez/forge/internal/config" | |
| 21 | "github.com/krazywarez/forge/internal/control" | |
| 22 | "github.com/krazywarez/forge/internal/gitutil" | |
| 15 | 23 | "github.com/krazywarez/forge/internal/policy" |
| 24 | "github.com/krazywarez/forge/internal/sig" | |
| 16 | 25 | "github.com/krazywarez/forge/internal/store" |
| 17 | 26 | ) |
| 18 | 27 | |
| @@ -31,9 +40,20 @@ type Request struct { | ||
| 31 | 40 | Updates []policy.RefUpdate `json:"updates"` |
| 32 | 41 | } |
| 33 | 42 | |
| 43 | type 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. | |
| 49 | type CommitsPayload struct { | |
| 50 | Commits []RawCommit `json:"commits"` | |
| 51 | } | |
| 52 | ||
| 34 | 53 | type 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"` | |
| 37 | 57 | } |
| 38 | 58 | |
| 39 | 59 | // SocketPath returns the hook socket location. It prefers the server root, |
| @@ -51,18 +71,19 @@ func SocketPath(root string) string { | ||
| 51 | 71 | } |
| 52 | 72 | |
| 53 | 73 | type Server struct { |
| 54 | st *store.Store | |
| 74 | cfg config.Config | |
| 75 | st *store.Store | |
| 55 | 76 | } |
| 56 | 77 | |
| 57 | 78 | // Serve listens on the unix socket until the listener is closed. |
| 58 | func Serve(root string, st *store.Store) (func() error, error) { | |
| 59 | path := SocketPath(root) | |
| 79 | func Serve(cfg config.Config, st *store.Store) (func() error, error) { | |
| 80 | path := SocketPath(cfg.Server.Root) | |
| 60 | 81 | os.Remove(path) |
| 61 | 82 | ln, err := net.Listen("unix", path) |
| 62 | 83 | if err != nil { |
| 63 | 84 | return nil, err |
| 64 | 85 | } |
| 65 | s := &Server{st: st} | |
| 86 | s := &Server{cfg: cfg, st: st} | |
| 66 | 87 | go func() { |
| 67 | 88 | for { |
| 68 | 89 | conn, err := ln.Accept() |
| @@ -77,48 +98,153 @@ func Serve(root string, st *store.Store) (func() error, error) { | ||
| 77 | 98 | |
| 78 | 99 | func (s *Server) handle(conn net.Conn) { |
| 79 | 100 | defer conn.Close() |
| 101 | dec := json.NewDecoder(conn) | |
| 102 | enc := json.NewEncoder(conn) | |
| 80 | 103 | 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"}) | |
| 83 | 106 | return |
| 84 | 107 | } |
| 85 | json.NewEncoder(conn).Encode(s.decide(req)) | |
| 86 | } | |
| 87 | ||
| 88 | func (s *Server) decide(req Request) Response { | |
| 89 | 108 | switch req.Hook { |
| 90 | 109 | 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 | ||
| 119 | func (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) | |
| 92 | 146 | 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 | |
| 94 | 149 | } |
| 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 | |
| 97 | 159 | } |
| 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)} | |
| 104 | 160 | } |
| 161 | enc.Encode(Response{Allow: true}) | |
| 105 | 162 | } |
| 106 | 163 | |
| 107 | // Ask sends one request from the hook process to the daemon. | |
| 108 | func 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. | |
| 168 | func (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 | ||
| 211 | func 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. | |
| 221 | func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) { | |
| 109 | 222 | conn, err := net.Dial("unix", socketPath) |
| 110 | 223 | if err != nil { |
| 111 | 224 | return Response{}, err |
| 112 | 225 | } |
| 113 | 226 | 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 { | |
| 115 | 230 | return Response{}, err |
| 116 | 231 | } |
| 117 | 232 | 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 { | |
| 119 | 244 | return Response{}, err |
| 120 | 245 | } |
| 121 | return resp, nil | |
| 246 | err = dec.Decode(&resp) | |
| 247 | return resp, err | |
| 122 | 248 | } |
| 123 | 249 | |
| 124 | 250 | // WriteHookScripts (re)generates the shared hooks directory. Called at |
internal/httpd/routes.go +2
| @@ -39,6 +39,8 @@ func (s *Server) Routes() []Route { | ||
| 39 | 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, |
| 40 | 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, |
| 41 | 41 | 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}, | |
| 42 | 44 | ) |
| 43 | 45 | |
| 44 | 46 | // 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) { | ||
| 244 | 244 | w.Write(data) |
| 245 | 245 | } |
| 246 | 246 | |
| 247 | type diffLine struct { | |
| 248 | Class string | |
| 249 | Text string | |
| 250 | } | |
| 251 | ||
| 252 | func 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 | ||
| 247 | 271 | type sigView struct { |
| 248 | 272 | State string |
| 249 | 273 | Signer string |
| @@ -329,25 +353,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 329 | 353 | return |
| 330 | 354 | } |
| 331 | 355 | 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) | |
| 351 | 357 | committerEmail := "" |
| 352 | 358 | if parsed.CommitterEmail != parsed.AuthorEmail { |
| 353 | 359 | committerEmail = parsed.CommitterEmail |
| @@ -413,6 +419,65 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | ||
| 413 | 419 | }{p, iss, comments}) |
| 414 | 420 | } |
| 415 | 421 | |
| 422 | func (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 | ||
| 447 | func (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 | ||
| 416 | 481 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
| 417 | 482 | p, ok := s.repoFor(w, r, "") |
| 418 | 483 | if !ok { |
internal/store/mrs.go added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | ) | |
| 7 | ||
| 8 | type 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 | ||
| 25 | type MRReview struct { | |
| 26 | Reviewer string | |
| 27 | Verdict string | |
| 28 | HeadSHA string | |
| 29 | Stale bool | |
| 30 | CreatedAt string | |
| 31 | } | |
| 32 | ||
| 33 | func (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 | ||
| 55 | const 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 | ||
| 66 | func 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 | ||
| 73 | func (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 | ||
| 81 | func (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. | |
| 107 | func (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 | ||
| 126 | func (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. | |
| 141 | func (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. | |
| 161 | func (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 | ||
| 168 | func (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 | ||
| 174 | func (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 | ||
| 194 | func (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 | ||
| 201 | func (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 "". | |
| 224 | func (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 { | ||
| 16 | 16 | Name string |
| 17 | 17 | Visibility string // public | private |
| 18 | 18 | DefaultBranch string |
| 19 | ForkOf int64 // 0 when not a fork | |
| 19 | 20 | Settings RepoSettings |
| 20 | 21 | } |
| 21 | 22 | |
| @@ -50,10 +51,10 @@ func (s *Store) RepoByPath(path string) (Repo, error) { | ||
| 50 | 51 | var r Repo |
| 51 | 52 | var settingsJSON string |
| 52 | 53 | 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 | |
| 54 | 55 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 55 | 56 | 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) | |
| 57 | 58 | if errors.Is(err, sql.ErrNoRows) { |
| 58 | 59 | return Repo{}, ErrNotFound |
| 59 | 60 | } |
| @@ -75,6 +76,11 @@ func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { | ||
| 75 | 76 | return err |
| 76 | 77 | } |
| 77 | 78 | |
| 79 | func (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 | ||
| 78 | 84 | func (s *Store) DeleteRepo(repoID int64) error { |
| 79 | 85 | res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID) |
| 80 | 86 | if err != nil { |
| @@ -176,10 +182,10 @@ func (s *Store) RepoByID(id int64) (Repo, error) { | ||
| 176 | 182 | var r Repo |
| 177 | 183 | var settingsJSON string |
| 178 | 184 | 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 | |
| 180 | 186 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 181 | 187 | 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) | |
| 183 | 189 | if errors.Is(err, sql.ErrNoRows) { |
| 184 | 190 | return Repo{}, ErrNotFound |
| 185 | 191 | } |
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}} | |