Commit 08b325bb1d
Verified · cmc
Layout: unified · split
cmd/forged/hook.go +33
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "bufio" | 4 | "bufio" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "os" | 6 | "os" |
| 7 | "os/exec" | ||
| 7 | "strconv" | 8 | "strconv" |
| 8 | "strings" | 9 | "strings" |
| 9 | 10 | ||
| @@ -14,6 +15,36 @@ import ( | |||
| 14 | "github.com/krazywarez/forge/internal/policy" | 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 | // hookCmd runs inside a git hook. It computes git facts here — the hook | 48 | // hookCmd runs inside a git hook. It computes git facts here — the hook |
| 18 | // process inherits git's quarantine environment, so incoming objects are | 49 | // process inherits git's quarantine environment, so incoming objects are |
| 19 | // visible — and asks the daemon for a policy decision over the unix socket. | 50 | // visible — and asks the daemon for a policy decision over the unix socket. |
| @@ -57,6 +88,8 @@ func hookCmd() *cobra.Command { | |||
| 57 | RepoID: repoID, | 88 | RepoID: repoID, |
| 58 | UserID: userID, | 89 | UserID: userID, |
| 59 | Updates: updates, | 90 | Updates: updates, |
| 91 | }, func() (hookd.CommitsPayload, error) { | ||
| 92 | return collectIncomingCommits(updates) | ||
| 60 | }) | 93 | }) |
| 61 | if err != nil { | 94 | if err != nil { |
| 62 | return fmt.Errorf("forge daemon unreachable: %w", err) | 95 | return fmt.Errorf("forge daemon unreachable: %w", err) |
cmd/forged/main.go +1 −1
| @@ -112,7 +112,7 @@ func serveCmd() *cobra.Command { | |||
| 112 | if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil { | 112 | if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil { |
| 113 | return err | 113 | return err |
| 114 | } | 114 | } |
| 115 | stopHookd, err := hookd.Serve(cfg.Server.Root, st) | 115 | stopHookd, err := hookd.Serve(cfg, st) |
| 116 | if err != nil { | 116 | if err != nil { |
| 117 | return err | 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 | if !yes { | 188 | if !yes { |
| 189 | return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") | 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 | if err := c.Store.DeleteRepo(repo.ID); err != nil { | 196 | if err := c.Store.DeleteRepo(repo.ID); err != nil { |
| 192 | return c.fail(protocol.ExitFailure, "%v", err) | 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 | // VerifyCommitCached verifies one commit with the epoch cache. Shared with | 98 | // VerifyCommitCached verifies one commit with the epoch cache. Shared with |
| 96 | // the web UI. | 99 | // the web UI. |
| 97 | func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) { | 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 | // Package hookd is the unix-socket bridge between git hooks and the daemon. | 1 | // Package hookd is the unix-socket bridge between git hooks and the daemon. |
| 2 | // The hook process (forged in hook mode) computes git facts — it inherits | 2 | // The hook process (forged in hook mode) computes git facts — it inherits |
| 3 | // git's quarantine environment, which the daemon does not see — and sends | 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 | package hookd | 9 | package hookd |
| 6 | 10 | ||
| 7 | import ( | 11 | import ( |
| 8 | "crypto/sha256" | 12 | "crypto/sha256" |
| 9 | "encoding/json" | 13 | "encoding/json" |
| 10 | "fmt" | 14 | "fmt" |
| 15 | "log/slog" | ||
| 11 | "net" | 16 | "net" |
| 12 | "os" | 17 | "os" |
| 13 | "path/filepath" | 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 | "github.com/krazywarez/forge/internal/policy" | 23 | "github.com/krazywarez/forge/internal/policy" |
| 24 | "github.com/krazywarez/forge/internal/sig" | ||
| 16 | "github.com/krazywarez/forge/internal/store" | 25 | "github.com/krazywarez/forge/internal/store" |
| 17 | ) | 26 | ) |
| 18 | 27 | ||
| @@ -31,9 +40,20 @@ type Request struct { | |||
| 31 | Updates []policy.RefUpdate `json:"updates"` | 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 | type Response struct { | 53 | type Response struct { |
| 35 | Allow bool `json:"allow"` | 54 | Allow bool `json:"allow"` |
| 36 | Message string `json:"message,omitempty"` | 55 | Message string `json:"message,omitempty"` |
| 56 | NeedCommits bool `json:"need_commits,omitempty"` | ||
| 37 | } | 57 | } |
| 38 | 58 | ||
| 39 | // SocketPath returns the hook socket location. It prefers the server root, | 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 | type Server struct { | 73 | type Server struct { |
| 54 | st *store.Store | 74 | cfg config.Config |
| 75 | st *store.Store | ||
| 55 | } | 76 | } |
| 56 | 77 | ||
| 57 | // Serve listens on the unix socket until the listener is closed. | 78 | // Serve listens on the unix socket until the listener is closed. |
| 58 | func Serve(root string, st *store.Store) (func() error, error) { | 79 | func Serve(cfg config.Config, st *store.Store) (func() error, error) { |
| 59 | path := SocketPath(root) | 80 | path := SocketPath(cfg.Server.Root) |
| 60 | os.Remove(path) | 81 | os.Remove(path) |
| 61 | ln, err := net.Listen("unix", path) | 82 | ln, err := net.Listen("unix", path) |
| 62 | if err != nil { | 83 | if err != nil { |
| 63 | return nil, err | 84 | return nil, err |
| 64 | } | 85 | } |
| 65 | s := &Server{st: st} | 86 | s := &Server{cfg: cfg, st: st} |
| 66 | go func() { | 87 | go func() { |
| 67 | for { | 88 | for { |
| 68 | conn, err := ln.Accept() | 89 | conn, err := ln.Accept() |
| @@ -77,48 +98,153 @@ func Serve(root string, st *store.Store) (func() error, error) { | |||
| 77 | 98 | ||
| 78 | func (s *Server) handle(conn net.Conn) { | 99 | func (s *Server) handle(conn net.Conn) { |
| 79 | defer conn.Close() | 100 | defer conn.Close() |
| 101 | dec := json.NewDecoder(conn) | ||
| 102 | enc := json.NewEncoder(conn) | ||
| 80 | var req Request | 103 | var req Request |
| 81 | if err := json.NewDecoder(conn).Decode(&req); err != nil { | 104 | if err := dec.Decode(&req); err != nil { |
| 82 | json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"}) | 105 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) |
| 83 | return | 106 | return |
| 84 | } | 107 | } |
| 85 | json.NewEncoder(conn).Encode(s.decide(req)) | ||
| 86 | } | ||
| 87 | |||
| 88 | func (s *Server) decide(req Request) Response { | ||
| 89 | switch req.Hook { | 108 | switch req.Hook { |
| 90 | case "pre-receive": | 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 | if err != nil { | 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 != "" { | 150 | res, err := sig.VerifyCommit(db, parsed) |
| 96 | return Response{Allow: false, Message: msg} | 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. | 164 | // postReceive applies the cross-repo MR effect: a push to a source branch |
| 108 | func Ask(socketPath string, req Request) (Response, error) { | 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 | conn, err := net.Dial("unix", socketPath) | 222 | conn, err := net.Dial("unix", socketPath) |
| 110 | if err != nil { | 223 | if err != nil { |
| 111 | return Response{}, err | 224 | return Response{}, err |
| 112 | } | 225 | } |
| 113 | defer conn.Close() | 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 | return Response{}, err | 230 | return Response{}, err |
| 116 | } | 231 | } |
| 117 | var resp Response | 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 | return Response{}, err | 244 | return Response{}, err |
| 120 | } | 245 | } |
| 121 | return resp, nil | 246 | err = dec.Decode(&resp) |
| 247 | return resp, err | ||
| 122 | } | 248 | } |
| 123 | 249 | ||
| 124 | // WriteHookScripts (re)generates the shared hooks directory. Called at | 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 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, | 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, |
| 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, | 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, |
| 41 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/{n}", Handler: s.issue}, | 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 | // Account-mode routes (login, web edits) are appended here in M8 — | 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 | w.Write(data) | 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 | type sigView struct { | 271 | type sigView struct { |
| 248 | State string | 272 | State string |
| 249 | Signer string | 273 | Signer string |
| @@ -329,25 +353,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | |||
| 329 | return | 353 | return |
| 330 | } | 354 | } |
| 331 | patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) | 355 | patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) |
| 332 | type diffLine struct { | 356 | lines := classifyDiff(patch) |
| 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 | } | ||
| 351 | committerEmail := "" | 357 | committerEmail := "" |
| 352 | if parsed.CommitterEmail != parsed.AuthorEmail { | 358 | if parsed.CommitterEmail != parsed.AuthorEmail { |
| 353 | committerEmail = parsed.CommitterEmail | 359 | committerEmail = parsed.CommitterEmail |
| @@ -413,6 +419,65 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | |||
| 413 | }{p, iss, comments}) | 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 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 481 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
| 417 | p, ok := s.repoFor(w, r, "") | 482 | p, ok := s.repoFor(w, r, "") |
| 418 | if !ok { | 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 | Name string | 16 | Name string |
| 17 | Visibility string // public | private | 17 | Visibility string // public | private |
| 18 | DefaultBranch string | 18 | DefaultBranch string |
| 19 | ForkOf int64 // 0 when not a fork | ||
| 19 | Settings RepoSettings | 20 | Settings RepoSettings |
| 20 | } | 21 | } |
| 21 | 22 | ||
| @@ -50,10 +51,10 @@ func (s *Store) RepoByPath(path string) (Repo, error) { | |||
| 50 | var r Repo | 51 | var r Repo |
| 51 | var settingsJSON string | 52 | var settingsJSON string |
| 52 | err := s.DB.QueryRow(` | 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 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | 55 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 55 | WHERE u.username = ? AND r.name = ?`, owner, name). | 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 | if errors.Is(err, sql.ErrNoRows) { | 58 | if errors.Is(err, sql.ErrNoRows) { |
| 58 | return Repo{}, ErrNotFound | 59 | return Repo{}, ErrNotFound |
| 59 | } | 60 | } |
| @@ -75,6 +76,11 @@ func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { | |||
| 75 | return err | 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 | func (s *Store) DeleteRepo(repoID int64) error { | 84 | func (s *Store) DeleteRepo(repoID int64) error { |
| 79 | res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID) | 85 | res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID) |
| 80 | if err != nil { | 86 | if err != nil { |
| @@ -176,10 +182,10 @@ func (s *Store) RepoByID(id int64) (Repo, error) { | |||
| 176 | var r Repo | 182 | var r Repo |
| 177 | var settingsJSON string | 183 | var settingsJSON string |
| 178 | err := s.DB.QueryRow(` | 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 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | 186 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 181 | WHERE r.id = ?`, id). | 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 | if errors.Is(err, sql.ErrNoRows) { | 189 | if errors.Is(err, sql.ErrNoRows) { |
| 184 | return Repo{}, ErrNotFound | 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}} | ||