krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
08b325bb1d221743ebfd2d99502f63b59ff6f346
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:11:09Z
cmd/forged/hook.go | 33 +++ cmd/forged/main.go | 2 +- e2e/mr_test.go | 260 ++++++++++++++++++ internal/control/mr.go | 595 ++++++++++++++++++++++++++++++++++++++++ internal/control/repo.go | 5 + internal/control/sig.go | 3 + internal/gitutil/merge.go | 117 ++++++++ internal/hookd/hookd.go | 182 ++++++++++-- internal/httpd/routes.go | 2 + internal/httpd/web.go | 103 +++++-- internal/store/mrs.go | 233 ++++++++++++++++ internal/store/repos.go | 14 +- internal/web/templates/mr.html | 15 + internal/web/templates/mrs.html | 16 ++ 14 files changed, 1528 insertions(+), 52 deletions(-) @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "os/exec" "strconv" "strings" @@ -14,6 +15,36 @@ import ( "github.com/krazywarez/forge/internal/policy" ) +// collectIncomingCommits lists the commits this push introduces and reads +// their raw objects. It runs in the hook process, which inherits git's +// quarantine environment — the daemon cannot see these objects yet. +func collectIncomingCommits(updates []policy.RefUpdate) (hookd.CommitsPayload, error) { + seen := map[string]bool{} + var payload hookd.CommitsPayload + for _, u := range updates { + if u.IsDelete { + continue + } + // Everything reachable from the new tip that no existing ref has. + out, err := exec.Command("git", "rev-list", u.New, "--not", "--all").Output() + if err != nil { + return payload, fmt.Errorf("rev-list %s: %w", u.New, err) + } + for _, sha := range strings.Fields(string(out)) { + if seen[sha] { + continue + } + seen[sha] = true + raw, err := exec.Command("git", "cat-file", "commit", sha).Output() + if err != nil { + return payload, fmt.Errorf("cat-file %s: %w", sha, err) + } + payload.Commits = append(payload.Commits, hookd.RawCommit{SHA: sha, Raw: raw}) + } + } + return payload, nil +} + // hookCmd runs inside a git hook. It computes git facts here — the hook // process inherits git's quarantine environment, so incoming objects are // visible — and asks the daemon for a policy decision over the unix socket. @@ -57,6 +88,8 @@ func hookCmd() *cobra.Command { RepoID: repoID, UserID: userID, Updates: updates, + }, func() (hookd.CommitsPayload, error) { + return collectIncomingCommits(updates) }) if err != nil { return fmt.Errorf("forge daemon unreachable: %w", err) @@ -112,7 +112,7 @@ func serveCmd() *cobra.Command { if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil { return err } - stopHookd, err := hookd.Serve(cfg.Server.Root, st) + stopHookd, err := hookd.Serve(cfg, st) if err != nil { return err } new file mode 100644 @@ -0,0 +1,260 @@ +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + + "github.com/krazywarez/forge/internal/sig" +) + +type mrShow struct { + Number int64 `json:"number"` + State string `json:"state"` + Source string `json:"source"` + HeadSHA string `json:"head_sha"` + Reviews []struct { + Reviewer string `json:"reviewer"` + Verdict string `json:"verdict"` + Stale bool `json:"stale"` + } `json:"reviews"` +} + +func (i *instance) mrShow(t *testing.T, key, repo, n string) mrShow { + t.Helper() + out, errOut, code := i.ssh(t, key, "", "mr", "show", repo, n, "--json") + if code != 0 { + t.Fatalf("mr show: exit %d, %s", code, errOut) + } + var env struct { + Data mrShow `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("mr show JSON: %v\n%s", err, out) + } + return env.Data +} + +func TestMergeRequests(t *testing.T) { + inst := startInstance(t) + + aliceKey := inst.newKey(t, "alice") + bobKey := inst.newKey(t, "bob") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + inst.admin(t, "admin", "user", "create", "bob", + "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") + + // Alice's upstream repo with an initial commit. + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 { + t.Fatalf("repo create: %s", errOut) + } + aliceEnv := inst.gitEnv(aliceKey) + aliceWork := t.TempDir() + mustGit(t, aliceWork, aliceEnv, "clone", inst.sshURL("alice/lib"), "w") + aliceDir := filepath.Join(aliceWork, "w") + os.WriteFile(filepath.Join(aliceDir, "lib.txt"), []byte("v1\n"), 0o644) + mustGit(t, aliceDir, aliceEnv, "checkout", "-q", "-b", "main") + mustGit(t, aliceDir, aliceEnv, "add", ".") + mustGit(t, aliceDir, aliceEnv, "commit", "-q", "-m", "base") + mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main") + + // Bob forks and pushes a feature branch to his fork. + if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/lib"); code != 0 { + t.Fatalf("fork: %s", errOut) + } + bobEnv := inst.gitEnv(bobKey) + bobWork := t.TempDir() + mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("bob/lib"), "w") + bobDir := filepath.Join(bobWork, "w") + mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main") + os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644) + mustGit(t, bobDir, bobEnv, "add", ".") + mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature") + mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature") + + // MR from the fork into alice/lib. + out, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib", + "--source", "bob/lib:feature", "--target", "main", "--title", "'add feature'", "--json") + if code != 0 { + t.Fatalf("mr create: %s", errOut) + } + if !strings.Contains(out, `"number":1`) { + t.Fatalf("mr create output: %s", out) + } + + // The MR head ref is fetchable from the TARGET repo by a reader. + fetchDir := t.TempDir() + mustGit(t, fetchDir, aliceEnv, "clone", "-q", inst.sshURL("alice/lib"), "c") + mustGit(t, filepath.Join(fetchDir, "c"), aliceEnv, "fetch", "-q", "origin", "refs/merge-requests/1/head") + + // Alice approves. + if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/lib", "1", "--approve"); code != 0 { + t.Fatalf("review: %s", errOut) + } + show := inst.mrShow(t, aliceKey, "alice/lib", "1") + if len(show.Reviews) != 1 || show.Reviews[0].Stale { + t.Fatalf("fresh review wrong: %+v", show.Reviews) + } + firstHead := show.HeadSHA + + // Bob force-pushes the source branch: the MR head updates and the + // review goes stale. + mustGit(t, bobDir, bobEnv, "commit", "-q", "--amend", "-m", "add feature (amended)") + mustGit(t, bobDir, bobEnv, "push", "-q", "--force", "origin", "feature") + show = inst.mrShow(t, aliceKey, "alice/lib", "1") + if show.HeadSHA == firstHead { + t.Fatal("MR head not updated after force-push") + } + if len(show.Reviews) != 1 || !show.Reviews[0].Stale { + t.Fatalf("review not marked stale: %+v", show.Reviews) + } + + // Target advances, so fast-forward is impossible: default merge makes a + // merge commit authored by the merging user. + mustGit(t, aliceDir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline moves on") + mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main") + out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--json") + if code != 0 { + t.Fatalf("merge: exit %d, %s", code, errOut) + } + if !strings.Contains(out, `"strategy":"merge"`) { + t.Fatalf("expected merge-commit strategy: %s", out) + } + if inst.mrShow(t, aliceKey, "alice/lib", "1").State != "merged" { + t.Fatal("MR not marked merged") + } + mustGit(t, aliceDir, aliceEnv, "pull", "-q", "origin", "main") + if _, err := os.Stat(filepath.Join(aliceDir, "feature.txt")); err != nil { + t.Fatal("merged content missing from main") + } + // The merge commit carries the merging user's identity and is unsigned. + tip := strings.TrimSpace(mustGit(t, aliceDir, aliceEnv, "log", "-1", "--format=%an <%ae>")) + if tip != "alice <alice@example.test>" { + t.Fatalf("merge commit identity: %q", tip) + } + logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/lib", "--limit", "1") + if !strings.Contains(logOut, "unsigned") { + t.Fatalf("merge commit should display unsigned:\n%s", logOut) + } + + // --- require_signed_commits: push-time and merge-time policy --- + + if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/sec"); code != 0 { + t.Fatalf("create sec: %s", errOut) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/sec", "on"); code != 0 { + t.Fatalf("require-signed: %s", errOut) + } + secWork := t.TempDir() + mustGit(t, secWork, aliceEnv, "clone", inst.sshURL("alice/sec"), "w") + secDir := filepath.Join(secWork, "w") + + // Unsigned push is rejected at pre-receive. + os.WriteFile(filepath.Join(secDir, "a.txt"), []byte("a\n"), 0o644) + mustGit(t, secDir, aliceEnv, "checkout", "-q", "-b", "main") + mustGit(t, secDir, aliceEnv, "add", ".") + mustGit(t, secDir, aliceEnv, "commit", "-q", "-m", "unsigned attempt") + pushOut, pushCode := gitRun(t, secDir, aliceEnv, "push", "origin", "main") + if pushCode == 0 { + t.Fatal("unsigned push accepted into require-signed repo") + } + if !strings.Contains(pushOut, "requires signed commits") { + t.Fatalf("unsigned push message:\n%s", pushOut) + } + + // SSHSIG-signed commits go through. + raw, _ := os.ReadFile(aliceKey) + signer, err := ssh.ParsePrivateKey(raw) + if err != nil { + t.Fatal(err) + } + signAlice := func(p []byte) string { + s, err := sig.MarshalSSHSig(signer, p) + if err != nil { + t.Fatal(err) + } + return string(s) + } + buildCommits(t, secDir, aliceEnv, []commitSpec{ + {authorEmail: "alice@example.test", subject: "signed base", sign: signAlice}, + }) + mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "main") + + // A signed feature branch and a same-repo MR. + base := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main")) + tree := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main^{tree}")) + buildChain(t, secDir, aliceEnv, tree, base, []commitSpec{ + {authorEmail: "alice@example.test", subject: "signed feature", sign: signAlice}, + }) + // buildChain moved refs/heads/main; restore and use a feature branch. + feat := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main")) + mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/main", base) + mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/feat", feat) + mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "feat") + + if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "create", "alice/sec", + "--source", "feat", "--target", "main", "--title", "'signed work'"); code != 0 { + t.Fatalf("sec mr create: %s", errOut) + } + + // An explicit merge-commit strategy is refused with exit 4 and rebase + // instructions. + _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--strategy", "merge") + if code != 4 { + t.Fatalf("merge-commit on require-signed: exit %d (want 4), %s", code, errOut) + } + if !strings.Contains(errOut, "only fast-forward") || !strings.Contains(errOut, "rebase") { + t.Fatalf("refusal message: %s", errOut) + } + + // Fast-forward merge of verified commits succeeds. + out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--json") + if code != 0 { + t.Fatalf("ff merge: %s", errOut) + } + if !strings.Contains(out, `"strategy":"ff"`) { + t.Fatalf("expected ff: %s", out) + } + + // --- fork deletion leaves the MR diff intact --- + + mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "second", "origin/main") + os.WriteFile(filepath.Join(bobDir, "second.txt"), []byte("more\n"), 0o644) + mustGit(t, bobDir, bobEnv, "add", ".") + mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "second feature") + mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "second") + if _, errOut, code = inst.ssh(t, bobKey, "", "mr", "create", "alice/lib", + "--source", "bob/lib:second", "--target", "main", "--title", "'second'"); code != 0 { + t.Fatalf("mr 2 create: %s", errOut) + } + if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "bob/lib", "--yes"); code != 0 { + t.Fatalf("fork delete: %s", errOut) + } + show = inst.mrShow(t, aliceKey, "alice/lib", "2") + if show.State != "source_gone" { + t.Fatalf("MR 2 state after fork deletion: %s", show.State) + } + diffOut, errOut, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "2") + if code != 0 || !strings.Contains(diffOut, "second.txt") { + t.Fatalf("diff after fork deletion: exit %d\n%s%s", code, diffOut, errOut) + } + // And it can still be merged: the target owns the objects. + if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2"); code != 0 { + t.Fatalf("merge after fork deletion: %s", errOut) + } + + // Web read views. + status, body := inst.get(t, "/alice/lib/mrs?state=all") + if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") { + t.Fatalf("mrs page: %d\n%s", status, body) + } + status, body = inst.get(t, "/alice/lib/mrs/1") + if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") { + t.Fatalf("mr detail: %d\n%s", status, body) + } +} new file mode 100644 @@ -0,0 +1,595 @@ +package control + +import ( + "errors" + "fmt" + "io" + "strconv" + "strings" + + "github.com/krazywarez/forge/internal/gitutil" + "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +func init() { + register(Command{Path: []string{"repo", "fork"}, + Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork}) + register(Command{Path: []string{"repo", "settings", "require-signed"}, + Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned}) + register(Command{Path: []string{"mr", "create"}, + Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]", + ReadsStdin: true, Run: runMRCreate}) + register(Command{Path: []string{"mr", "list"}, + Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", Run: runMRList}) + register(Command{Path: []string{"mr", "show"}, + Summary: "show a merge request: mr show <owner/name> <n>", Run: runMRShow}) + register(Command{Path: []string{"mr", "diff"}, + Summary: "show the diff: mr diff <owner/name> <n>", Run: runMRDiff}) + register(Command{Path: []string{"mr", "comment"}, + Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", + ReadsStdin: true, Run: runMRComment}) + register(Command{Path: []string{"mr", "review"}, + Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview}) + register(Command{Path: []string{"mr", "merge"}, + Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge]", Run: runMRMerge}) + register(Command{Path: []string{"mr", "close"}, + Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose}) +} + +func runRepoFork(c *Ctx, args []string) int { + var path, name string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--name": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--name requires a value") + } + name = args[i+1] + i++ + default: + if path != "" { + return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]") + } + path = args[i] + } + } + if path == "" { + return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]") + } + src, code := resolveRepo(c, path, policy.CanRead) + if code >= 0 { + return code + } + if name == "" { + name = src.Name + } + if err := policy.ValidateName(name); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := c.Store.SetForkOf(id, src.ID); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name) + srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name) + if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil { + c.Store.DeleteRepo(id) + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil { + // Empty source repos have nothing to fetch; that is fine. + if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil { + c.Store.DeleteRepo(id) + return c.fail(protocol.ExitFailure, "copying refs: %v", err) + } + } + forkPath := c.User.Username + "/" + name + return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) { + fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) + }) +} + +func runRequireSigned(c *Ctx, args []string) int { + if len(args) != 2 || (args[1] != "on" && args[1] != "off") { + return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + s := repo.Settings + s.RequireSignedCommits = args[1] == "on" + if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(s, func(w io.Writer) { + fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path()) + }) +} + +// mrRef parses "<owner/name> <n>" and loads the MR. +func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) { + if len(args) < 2 { + return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>") + } + repo, code := resolveRepo(c, args[0], perm) + if code >= 0 { + return repo, store.MR{}, code + } + n, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1]) + } + mr, err := c.Store.MRByNumber(repo.ID, n) + if errors.Is(err, store.ErrNotFound) { + return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path()) + } + if err != nil { + return repo, mr, c.fail(protocol.ExitFailure, "%v", err) + } + return repo, mr, -1 +} + +func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } + +func runMRCreate(c *Ctx, args []string) int { + var path, source, target, title, body, file string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--source", "--target", "--title", "--body", "--file": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) + } + v := args[i+1] + switch args[i] { + case "--source": + source = v + case "--target": + target = v + case "--title": + title = v + case "--body": + body = v + case "--file": + file = v + } + i++ + default: + if path != "" { + return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) + } + path = args[i] + } + } + if path == "" || source == "" || title == "" { + return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>") + } + repo, code := resolveRepo(c, path, policy.CanRead) + if code >= 0 { + return code + } + if target == "" { + target = repo.DefaultBranch + } + + // Source is "branch" (same repo) or "owner/name:branch" (a fork). + srcRepo := repo + srcBranch := source + if sp, br, ok := strings.Cut(source, ":"); ok { + srcBranch = br + var scode int + srcRepo, scode = resolveRepo(c, sp, policy.CanRead) + if scode >= 0 { + return scode + } + if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID { + return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path()) + } + } + srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name) + headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch) + if err != nil { + return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path()) + } + b, err := bodyFrom(c, body, file) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + // Fetch the head into the target so the target owns the objects. + dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) + if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil { + return c.fail(protocol.ExitFailure, "recording MR head: %v", err) + } + c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) + return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) { + fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target) + }) +} + +type mrOut struct { + Number int64 `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Author string `json:"author"` + Source string `json:"source"` // owner/name:branch, or branch, "" if gone + TargetRef string `json:"target_ref"` + HeadSHA string `json:"head_sha"` + Body string `json:"body,omitempty"` + CreatedAt string `json:"created_at"` +} + +func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { + src := "" + if m.SourcePath != "" { + if m.SourceRepoID == repo.ID { + src = m.SourceRef + } else { + src = m.SourcePath + ":" + m.SourceRef + } + } + o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author, + Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt} + if withBody { + o.Body = m.Body + } + return o +} + +func runMRList(c *Ctx, args []string) int { + state := "open" + var path string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--state": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--state requires a value") + } + state = args[i+1] + i++ + default: + if path != "" { + return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) + } + path = args[i] + } + } + valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} + if path == "" || !valid[state] { + return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all]") + } + repo, code := resolveRepo(c, path, policy.CanRead) + if code >= 0 { + return code + } + mrs, err := c.Store.ListMRs(repo.ID, state) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + var ds []mrOut + for _, m := range mrs { + ds = append(ds, mrToOut(repo, m, false)) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef) + } + }) +} + +func runMRShow(c *Ctx, args []string) int { + repo, mr, code := mrRef(c, args, policy.CanRead) + if code >= 0 { + return code + } + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>") + } + comments, err := c.Store.ListMRComments(mr.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + reviews, err := c.Store.ListMRReviews(mr.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type commentOut struct { + Author string `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + } + type reviewOut struct { + Reviewer string `json:"reviewer"` + Verdict string `json:"verdict"` + Stale bool `json:"stale"` + } + var cs []commentOut + for _, cm := range comments { + cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt}) + } + var rs []reviewOut + for _, r := range reviews { + rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale}) + } + d := struct { + mrOut + Comments []commentOut `json:"comments,omitempty"` + Reviews []reviewOut `json:"reviews,omitempty"` + }{mrToOut(repo, mr, true), cs, rs} + return c.emit(d, func(w io.Writer) { + 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) + if d.Body != "" { + fmt.Fprintf(w, "\n%s\n", d.Body) + } + for _, r := range rs { + stale := "" + if r.Stale { + stale = " (stale)" + } + fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale) + } + for _, cm := range cs { + fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) + } + }) +} + +func runMRDiff(c *Ctx, args []string) int { + repo, mr, code := mrRef(c, args, policy.CanRead) + if code >= 0 { + return code + } + dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) + head := mrHeadRef(mr.Number) + base, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + patch, err := gitutil.Diff(dir, base, head, 4<<20) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + fmt.Fprint(c.Stdout, patch) + return protocol.ExitOK +} + +func runMRComment(c *Ctx, args []string) int { + var rest []string + var message, file string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--message", "--file": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) + } + if args[i] == "--message" { + message = args[i+1] + } else { + file = args[i+1] + } + i++ + default: + rest = append(rest, args[i]) + } + } + repo, mr, code := mrRef(c, rest, policy.CanRead) + if code >= 0 { + return code + } + body, err := bodyFrom(c, message, file) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if strings.TrimSpace(body) == "" { + return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") + } + if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { + fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number) + }) +} + +func runMRReview(c *Ctx, args []string) int { + verdict := "" + var rest []string + for _, a := range args { + switch a { + case "--approve": + verdict = "approve" + case "--request-changes": + verdict = "request_changes" + case "--comment": + verdict = "comment" + default: + rest = append(rest, a) + } + } + if verdict == "" { + return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment") + } + repo, mr, code := mrRef(c, rest, policy.CanRead) + if code >= 0 { + return code + } + if mr.State != "open" { + return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) + } + if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) { + fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict) + }) +} + +func runMRMerge(c *Ctx, args []string) int { + strategy := "" + var rest []string + for i := 0; i < len(args); i++ { + if args[i] == "--strategy" { + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--strategy requires ff|merge") + } + strategy = args[i+1] + i++ + continue + } + rest = append(rest, args[i]) + } + if strategy != "" && strategy != "ff" && strategy != "merge" { + return c.fail(protocol.ExitUsage, "--strategy must be ff or merge") + } + repo, mr, code := mrRef(c, rest, policy.CanWrite) + if code >= 0 { + return code + } + if mr.State != "open" && mr.State != "source_gone" { + return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) + } + + dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) + targetRef := "refs/heads/" + mr.TargetRef + targetSHA, err := gitutil.ResolveRef(dir, targetRef) + if err != nil { + return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err) + } + headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number)) + if err != nil { + return c.fail(protocol.ExitFailure, "MR head ref: %v", err) + } + + upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if upToDate { + return c.fail(protocol.ExitUsage, "target already contains the MR head") + } + ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + + // Signature policy matrix: with require_signed_commits, only + // fast-forward is allowed — a server-created merge commit would be + // unsigned, violating the branch's own policy — and every landed + // commit must be verified. + if repo.Settings.RequireSignedCommits { + if strategy == "merge" || !ffPossible { + return c.fail(protocol.ExitDenied, + "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again", + repo.Path(), mr.SourceRef, mr.TargetRef) + } + strategy = "ff" + commits, err := gitutil.RevListRange(dir, targetSHA, headSHA) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + for _, sha := range commits { + raw, err := gitutil.ReadCommit(dir, sha) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + parsed, err := sigParse(raw) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + res, err := VerifyCommitCached(c.Store, repo, parsed, sha) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if res.State != "verified" { + return c.fail(protocol.ExitDenied, + "%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State) + } + } + } + if strategy == "" { + if ffPossible { + strategy = "ff" + } else { + strategy = "merge" + } + } + + var newSHA string + switch strategy { + case "ff": + if !ffPossible { + return c.fail(protocol.ExitUsage, + "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef) + } + newSHA = headSHA + case "merge": + email, err := c.Store.PrimaryVerifiedEmail(c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if email == "" { + return c.fail(protocol.ExitDenied, + "merge commits carry your identity: verify a primary email first (ask an admin, or use a fast-forward merge)") + } + tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if conflict { + return c.fail(protocol.ExitUsage, + "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef) + } + msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef) + newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, email, msg) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + } + + // CAS so a concurrent push between our read and this write fails the + // merge instead of silently discarding the push. + if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil { + return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err) + } + if err := c.Store.SetMRState(mr.ID, "merged"); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) + return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) { + fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA) + }) +} + +func runMRClose(c *Ctx, args []string) int { + repo, mr, code := mrRef(c, args, policy.CanRead) + if code >= 0 { + return code + } + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>") + } + grant, err := c.Store.AccessRole(repo.ID, c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { + return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR") + } + if mr.State == "merged" || mr.State == "closed" { + return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State) + } + if err := c.Store.SetMRState(mr.ID, "closed"); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) { + fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number) + }) +} @@ -188,6 +188,11 @@ func runRepoDelete(c *Ctx, args []string) int { if !yes { return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") } + // Open MRs sourced from this repo keep working (targets own the + // objects) but must show that the source is gone. + if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } if err := c.Store.DeleteRepo(repo.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } @@ -92,6 +92,9 @@ func runPGPRemove(c *Ctx, args []string) int { }) } +// sigParse is a package-local alias so callers avoid importing sig directly. +func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) } + // VerifyCommitCached verifies one commit with the epoch cache. Shared with // the web UI. func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) { new file mode 100644 @@ -0,0 +1,117 @@ +package gitutil + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the +// update. Objects are copied, not shared — the destination owns everything +// afterward, which is what keeps MRs alive when their fork is deleted. +func FetchInto(dstDir, srcDir, srcRef, dstRef string) error { + cmd := exec.Command("git", "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head", + srcDir, "+"+srcRef+":"+dstRef) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out) + } + return nil +} + +// UpdateRefCAS points ref at newSHA only if it currently points at oldSHA +// (empty oldSHA = must not exist). This is the compare-and-swap that makes +// merges safe against concurrent pushes. +func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error { + args := []string{"-C", dir, "update-ref", ref, newSHA} + if oldSHA != "" { + args = append(args, oldSHA) + } + cmd := exec.Command("git", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out) + } + return nil +} + +func DeleteRef(dir, ref string) error { + cmd := exec.Command("git", "-C", dir, "update-ref", "-d", ref) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out) + } + return nil +} + +// RevListRange returns commits in old..new, newest first. +func RevListRange(dir, old, new string) ([]string, error) { + cmd := exec.Command("git", "-C", dir, "rev-list", new, "^"+old) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err) + } + var shas []string + for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if l != "" { + shas = append(shas, l) + } + } + return shas, nil +} + +// MergeTree performs a real merge of ours and theirs, returning the merged +// tree id. conflict=true means the merge cannot be done automatically. +func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) { + cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", ours, theirs) + out, runErr := cmd.Output() + tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) + if runErr != nil { + if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return "", true, nil // conflicted merge + } + return "", false, fmt.Errorf("merge-tree: %w", runErr) + } + return tree, false, nil +} + +// CommitTree creates a merge commit with the given parents, authored and +// committed by the merging user. There is no server signing key by design. +func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) { + args := []string{"-C", dir, "commit-tree", tree, "-m", message} + for _, p := range parents { + args = append(args, "-p", p) + } + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email, + "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email, + ) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("commit-tree: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// Diff returns the patch for old..new (three-dot semantics are the caller's +// job: pass the merge base as old). +func Diff(dir, old, new string, limit int64) (string, error) { + cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", old, new) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("diff: %w", err) + } + if int64(len(out)) > limit { + out = out[:limit] + } + return string(out), nil +} + +// MergeBase returns the best common ancestor, or an error if none exists. +func MergeBase(dir, a, b string) (string, error) { + cmd := exec.Command("git", "-C", dir, "merge-base", a, b) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("no common history between %s and %s", a, b) + } + return strings.TrimSpace(string(out)), nil +} @@ -1,18 +1,27 @@ // Package hookd is the unix-socket bridge between git hooks and the daemon. // The hook process (forged in hook mode) computes git facts — it inherits // git's quarantine environment, which the daemon does not see — and sends -// them here; the daemon answers with a pure policy decision. +// them here; the daemon answers with a policy decision. +// +// pre-receive is two-phase when the repo requires signed commits: the first +// response sets NeedCommits, and the hook answers with the raw commit +// objects (only the hook can read them out of quarantine) for verification. package hookd import ( "crypto/sha256" "encoding/json" "fmt" + "log/slog" "net" "os" "path/filepath" + "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/control" + "github.com/krazywarez/forge/internal/gitutil" "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/sig" "github.com/krazywarez/forge/internal/store" ) @@ -31,9 +40,20 @@ type Request struct { Updates []policy.RefUpdate `json:"updates"` } +type RawCommit struct { + SHA string `json:"sha"` + Raw []byte `json:"raw"` +} + +// CommitsPayload is the hook's second message when NeedCommits was set. +type CommitsPayload struct { + Commits []RawCommit `json:"commits"` +} + type Response struct { - Allow bool `json:"allow"` - Message string `json:"message,omitempty"` + Allow bool `json:"allow"` + Message string `json:"message,omitempty"` + NeedCommits bool `json:"need_commits,omitempty"` } // SocketPath returns the hook socket location. It prefers the server root, @@ -51,18 +71,19 @@ func SocketPath(root string) string { } type Server struct { - st *store.Store + cfg config.Config + st *store.Store } // Serve listens on the unix socket until the listener is closed. -func Serve(root string, st *store.Store) (func() error, error) { - path := SocketPath(root) +func Serve(cfg config.Config, st *store.Store) (func() error, error) { + path := SocketPath(cfg.Server.Root) os.Remove(path) ln, err := net.Listen("unix", path) if err != nil { return nil, err } - s := &Server{st: st} + s := &Server{cfg: cfg, st: st} go func() { for { conn, err := ln.Accept() @@ -77,48 +98,153 @@ func Serve(root string, st *store.Store) (func() error, error) { func (s *Server) handle(conn net.Conn) { defer conn.Close() + dec := json.NewDecoder(conn) + enc := json.NewEncoder(conn) var req Request - if err := json.NewDecoder(conn).Decode(&req); err != nil { - json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"}) + if err := dec.Decode(&req); err != nil { + enc.Encode(Response{Allow: false, Message: "bad hook request"}) return } - json.NewEncoder(conn).Encode(s.decide(req)) -} - -func (s *Server) decide(req Request) Response { switch req.Hook { case "pre-receive": - repo, err := s.st.RepoByID(req.RepoID) + s.preReceive(req, dec, enc) + case "post-receive": + s.postReceive(req) + enc.Encode(Response{Allow: true}) + default: + enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}) + } +} + +func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { + repo, err := s.st.RepoByID(req.RepoID) + if err != nil { + enc.Encode(Response{Allow: false, Message: "unknown repository"}) + return + } + if msg := policy.CheckPush(repo, req.Updates); msg != "" { + enc.Encode(Response{Allow: false, Message: msg}) + return + } + if !repo.Settings.RequireSignedCommits { + enc.Encode(Response{Allow: true}) + return + } + + // Phase two: ask the hook for the incoming commit objects. + if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil { + return + } + var payload CommitsPayload + if err := dec.Decode(&payload); err != nil { + enc.Encode(Response{Allow: false, Message: "bad commits payload"}) + return + } + db := store.SigDB{Store: s.st} + for _, rc := range payload.Commits { + parsed, err := sig.ParseCommit(rc.Raw) if err != nil { - return Response{Allow: false, Message: "unknown repository"} + enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unparseable commit %s", rc.SHA)}) + return } - if msg := policy.CheckPush(repo, req.Updates); msg != "" { - return Response{Allow: false, Message: msg} + res, err := sig.VerifyCommit(db, parsed) + if err != nil || res.State != sig.Verified { + state := "error" + if err == nil { + state = string(res.State) + } + enc.Encode(Response{Allow: false, Message: fmt.Sprintf( + "this repository requires signed commits: %.10s is %s", rc.SHA, state)}) + return } - return Response{Allow: true} - case "post-receive": - // Event recording and signature verification enqueue land in M4. - return Response{Allow: true} - default: - return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)} } + enc.Encode(Response{Allow: true}) } -// Ask sends one request from the hook process to the daemon. -func Ask(socketPath string, req Request) (Response, error) { +// postReceive applies the cross-repo MR effect: a push to a source branch +// refreshes refs/merge-requests/N/head in every target repo, by fetching — +// the target owns the objects, so the MR outlives the fork. This is the only +// place a hook writes outside its own repository. +func (s *Server) postReceive(req Request) { + for _, u := range req.Updates { + branch, ok := cutHeads(u.Ref) + if !ok { + continue + } + mrs, err := s.st.OpenMRsBySource(req.RepoID, branch) + if err != nil { + slog.Error("post-receive: listing MRs", "err", err) + continue + } + srcRepo, err := s.st.RepoByID(req.RepoID) + if err != nil { + continue + } + srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name) + for _, mr := range mrs { + target, err := s.st.RepoByID(mr.RepoID) + if err != nil { + continue + } + if u.IsDelete { + if mr.State == "open" { + s.st.SetMRState(mr.ID, "source_gone") + } + continue // head ref retained: the diff stays viewable + } + dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name) + headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number) + if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil { + slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err) + continue + } + if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil { + slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err) + } + if mr.State == "source_gone" { + s.st.SetMRState(mr.ID, "open") // branch came back + } + } + } +} + +func cutHeads(ref string) (string, bool) { + const p = "refs/heads/" + if len(ref) > len(p) && ref[:len(p)] == p { + return ref[len(p):], true + } + return "", false +} + +// Ask sends one request from the hook process to the daemon. commits is +// called if the daemon asks for the incoming commit objects. +func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) { conn, err := net.Dial("unix", socketPath) if err != nil { return Response{}, err } defer conn.Close() - if err := json.NewEncoder(conn).Encode(req); err != nil { + enc := json.NewEncoder(conn) + dec := json.NewDecoder(conn) + if err := enc.Encode(req); err != nil { return Response{}, err } var resp Response - if err := json.NewDecoder(conn).Decode(&resp); err != nil { + if err := dec.Decode(&resp); err != nil { + return Response{}, err + } + if !resp.NeedCommits { + return resp, nil + } + payload, err := commits() + if err != nil { + return Response{}, err + } + if err := enc.Encode(payload); err != nil { return Response{}, err } - return resp, nil + err = dec.Decode(&resp) + return resp, err } // WriteHookScripts (re)generates the shared hooks directory. Called at @@ -39,6 +39,8 @@ func (s *Server) Routes() []Route { Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/{n}", Handler: s.issue}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs", Handler: s.mrs}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr}, ) // Account-mode routes (login, web edits) are appended here in M8 — @@ -244,6 +244,30 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { w.Write(data) } +type diffLine struct { + Class string + Text string +} + +func classifyDiff(patch string) []diffLine { + var lines []diffLine + for _, l := range strings.Split(patch, "\n") { + class := "" + switch { + case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "): + class = "meta" + case strings.HasPrefix(l, "@@"): + class = "hunk" + case strings.HasPrefix(l, "+"): + class = "add" + case strings.HasPrefix(l, "-"): + class = "del" + } + lines = append(lines, diffLine{class, l}) + } + return lines +} + type sigView struct { State string Signer string @@ -329,25 +353,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { return } patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) - type diffLine struct { - Class string - Text string - } - var lines []diffLine - for _, l := range strings.Split(patch, "\n") { - class := "" - switch { - case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "): - class = "meta" - case strings.HasPrefix(l, "@@"): - class = "hunk" - case strings.HasPrefix(l, "+"): - class = "add" - case strings.HasPrefix(l, "-"): - class = "del" - } - lines = append(lines, diffLine{class, l}) - } + lines := classifyDiff(patch) committerEmail := "" if parsed.CommitterEmail != parsed.AuthorEmail { committerEmail = parsed.CommitterEmail @@ -413,6 +419,65 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { }{p, iss, comments}) } +func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + state := r.URL.Query().Get("state") + if state == "" { + state = "open" + } + valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} + if !valid[state] { + state = "open" + } + mrs, err := s.st.ListMRs(p.Repo.ID, state) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, "mrs.html", struct { + repoPage + State string + MRs []store.MR + }{p, state, mrs}) +} + +func (s *Server) mr(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + m, err := s.st.MRByNumber(p.Repo.ID, n) + if err != nil { + http.NotFound(w, r) + return + } + comments, _ := s.st.ListMRComments(m.ID) + reviews, _ := s.st.ListMRReviews(m.ID) + + headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) + var lines []diffLine + if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil { + if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil { + lines = classifyDiff(patch) + } + } + s.render(w, "mr.html", struct { + repoPage + MR store.MR + Comments []store.IssueComment + Reviews []store.MRReview + DiffLines []diffLine + }{p, m, comments, reviews, lines}) +} + func (s *Server) refs(w http.ResponseWriter, r *http.Request) { p, ok := s.repoFor(w, r, "") if !ok { new file mode 100644 @@ -0,0 +1,233 @@ +package store + +import ( + "database/sql" + "errors" +) + +type MR struct { + ID int64 + RepoID int64 + Number int64 + Author string + SourceRepoID int64 // 0 when the source repo is gone + SourcePath string // owner/name of source repo, "" when gone + SourceRef string + TargetRef string + Title string + Body string + State string // open | merged | closed | source_gone + HeadSHA string + CreatedAt string + UpdatedAt string +} + +type MRReview struct { + Reviewer string + Verdict string + HeadSHA string + Stale bool + CreatedAt string +} + +func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA string) (int64, error) { + tx, err := s.DB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil { + return 0, err + } + var n int64 + if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil { + return 0, err + } + if _, err := tx.Exec(` + INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA); err != nil { + return 0, err + } + return n, tx.Commit() +} + +const mrSelect = ` + SELECT m.id, m.repo_id, m.number, u.username, + COALESCE(m.source_repo_id, 0), + COALESCE(su.username || '/' || sr.name, ''), + m.source_ref, m.target_ref, m.title, m.body, m.state, m.head_sha, + m.created_at, m.updated_at + FROM merge_requests m + JOIN users u ON u.id = m.author_id + LEFT JOIN repos sr ON sr.id = m.source_repo_id + LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id` + +func scanMR(row interface{ Scan(...any) error }) (MR, error) { + var m MR + err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, + &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.CreatedAt, &m.UpdatedAt) + return m, err +} + +func (s *Store) MRByNumber(repoID, number int64) (MR, error) { + m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number)) + if errors.Is(err, sql.ErrNoRows) { + return m, ErrNotFound + } + return m, err +} + +func (s *Store) ListMRs(repoID int64, state string) ([]MR, error) { + q := mrSelect + " WHERE m.repo_id = ?" + args := []any{repoID} + if state != "all" { + q += " AND m.state = ?" + args = append(args, state) + } + q += " ORDER BY m.number DESC" + rows, err := s.DB.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MR + for rows.Next() { + m, err := scanMR(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// OpenMRsBySource returns open (and source_gone) MRs fed by the given source +// repo branch — the cross-repo hook effect consults this. +func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) { + rows, err := s.DB.Query( + mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')", + sourceRepoID, sourceRef) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MR + for rows.Next() { + m, err := scanMR(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) SetMRState(mrID int64, state string) error { + res, err := s.DB.Exec( + "UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + state, mrID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// UpdateMRHead records a new head and marks every review at another head +// stale, in one transaction. +func (s *Store) UpdateMRHead(mrID int64, headSHA string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec( + "UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + headSHA, mrID); err != nil { + return err + } + if _, err := tx.Exec( + "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil { + return err + } + return tx.Commit() +} + +// MarkSourceGoneForRepo flags every open MR sourced from the repo; called +// when a fork is deleted. Head refs in the target repos are retained. +func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error { + _, err := s.DB.Exec( + "UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'", + sourceRepoID) + return err +} + +func (s *Store) AddMRComment(mrID, authorID int64, body string) error { + _, err := s.DB.Exec( + "INSERT INTO mr_comments (mr_id, author_id, body) VALUES (?, ?, ?)", mrID, authorID, body) + return err +} + +func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { + rows, err := s.DB.Query(` + SELECT u.username, c.body, c.created_at + FROM mr_comments c JOIN users u ON u.id = c.author_id + WHERE c.mr_id = ? ORDER BY c.id`, mrID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []IssueComment + for rows.Next() { + var c IssueComment + if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error { + _, err := s.DB.Exec( + "INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)", + mrID, reviewerID, verdict, headSHA) + return err +} + +func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) { + rows, err := s.DB.Query(` + SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at + FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id + WHERE r.mr_id = ? ORDER BY r.id`, mrID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MRReview + for rows.Next() { + var r MRReview + var stale int + if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil { + return nil, err + } + r.Stale = stale != 0 + out = append(out, r) + } + return out, rows.Err() +} + +// PrimaryVerifiedEmail returns the user's primary email if verified, else "". +func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) { + var addr string + err := s.DB.QueryRow( + "SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL", + userID).Scan(&addr) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return addr, err +} @@ -16,6 +16,7 @@ type Repo struct { Name string Visibility string // public | private DefaultBranch string + ForkOf int64 // 0 when not a fork Settings RepoSettings } @@ -50,10 +51,10 @@ func (s *Store) RepoByPath(path string) (Repo, error) { var r Repo var settingsJSON string err := s.DB.QueryRow(` - SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json + 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 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id WHERE u.username = ? AND r.name = ?`, owner, name). - Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON) + Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) if errors.Is(err, sql.ErrNoRows) { return Repo{}, ErrNotFound } @@ -75,6 +76,11 @@ func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { return err } +func (s *Store) SetForkOf(repoID, parentID int64) error { + _, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID) + return err +} + func (s *Store) DeleteRepo(repoID int64) error { res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID) if err != nil { @@ -176,10 +182,10 @@ func (s *Store) RepoByID(id int64) (Repo, error) { var r Repo var settingsJSON string err := s.DB.QueryRow(` - SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json + 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 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id WHERE r.id = ?`, id). - Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON) + Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) if errors.Is(err, sql.ErrNoRows) { return Repo{}, ErrNotFound } new file mode 100644 @@ -0,0 +1,15 @@ +{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2> +<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} + @ <code>{{.MR.HeadSHA}}</code></p> +{{if .MR.Body}}<pre class="message">{{.MR.Body}}</pre>{{end}} +{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} +{{range .Comments}} +<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> +{{end}} +<h3>diff</h3> +<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> +{{end}}</pre> +{{end}} new file mode 100644 @@ -0,0 +1,16 @@ +{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<h2>merge requests ({{.State}})</h2> +<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> +<table> +{{range .MRs}}<tr> + <td>!{{.Number}}</td> + <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></td> + <td>{{.State}}</td> + <td>{{.Author}}</td> + <td>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</td> +</tr> +{{else}}<tr><td>no merge requests</td></tr>{{end}} +</table> +{{end}}