Federated via graft
5.0 kB
164 lines
1package sync
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "regexp"
9 "strings"
10
11 "graft/internal/forgejo"
12 "graft/internal/radicle"
13 "graft/internal/state"
14)
15
16// PatchSyncer mirrors Forgejo pull requests and Radicle patches. It reuses
17// the same working clone GitSyncer maintains, since a patch/PR can only be
18// opened once its head commit is reachable on the destination side.
19type PatchSyncer struct {
20 RepoPair string
21 WorkDir string
22 RadHome string
23 DefaultBranch string
24 Forgejo *forgejo.Client
25 Radicle *radicle.Client
26 State *state.Store
27}
28
29func (s *PatchSyncer) git(args ...string) (string, error) {
30 cmd := exec.Command("git", args...)
31 cmd.Dir = s.WorkDir
32 // Must start from the inherited environment, not a bare slice: git
33 // needs PATH to resolve the git-remote-rad helper for rad:// remotes.
34 cmd.Env = append(os.Environ(), "RAD_HOME="+s.RadHome)
35 var out bytes.Buffer
36 cmd.Stdout = &out
37 cmd.Stderr = &out
38 if err := cmd.Run(); err != nil {
39 return out.String(), fmt.Errorf("git %v: %w: %s", args, err, out.String())
40 }
41 return out.String(), nil
42}
43
44func (s *PatchSyncer) Sync() error {
45 prs, err := s.Forgejo.ListPullRequests()
46 if err != nil {
47 return fmt.Errorf("list forgejo pull requests: %w", err)
48 }
49 patches, err := s.Radicle.ListPatches()
50 if err != nil {
51 return fmt.Errorf("list radicle patches: %w", err)
52 }
53
54 for _, pr := range prs {
55 if err := s.mirrorForgejoToRadicle(pr); err != nil {
56 return fmt.Errorf("mirror forgejo PR #%d: %w", pr.Index, err)
57 }
58 }
59 for _, p := range patches {
60 if err := s.mirrorRadicleToForgejo(p); err != nil {
61 return fmt.Errorf("mirror radicle patch %s: %w", p.ID, err)
62 }
63 }
64 return nil
65}
66
67var patchOpenedRe = regexp.MustCompile(`Patch ([0-9a-f]{40}) opened`)
68
69func (s *PatchSyncer) mirrorForgejoToRadicle(pr forgejo.PullRequest) error {
70 existing, err := s.State.FindByForgejoID(s.RepoPair, "patch", pr.Index)
71 if err != nil {
72 return err
73 }
74 if existing != nil {
75 return nil
76 }
77
78 // Bring the PR's head branch content into the local clone, then open a
79 // patch from it via the pseudo-ref, exactly as verified interactively:
80 // `git push rad HEAD:refs/patches`.
81 if _, err := s.git("fetch", "-q", "forgejo", pr.Head.Ref); err != nil {
82 return fmt.Errorf("fetch PR head: %w", err)
83 }
84 if _, err := s.git("checkout", "-q", "-B", localPRBranch(pr.Index), "FETCH_HEAD"); err != nil {
85 return fmt.Errorf("checkout PR head: %w", err)
86 }
87 // git push -o values cannot contain newlines, unlike issue/PR bodies.
88 message := pr.Title + " (mirrored automatically, do not edit here)"
89 out, err := s.git("push", "rad", "-o", "patch.message="+message, "HEAD:refs/patches")
90 // The working clone's mirroring never touches local branch refs (only
91 // FETCH_HEAD and the remotes), so there may be no local default-branch
92 // ref to switch back to yet: fetch and (re)create it rather than assume
93 // it exists.
94 if _, errFetch := s.git("fetch", "-q", "forgejo", s.DefaultBranch); errFetch != nil {
95 return fmt.Errorf("fetch %s to restore: %w", s.DefaultBranch, errFetch)
96 }
97 if _, errCheckout := s.git("checkout", "-q", "-B", s.DefaultBranch, "FETCH_HEAD"); errCheckout != nil {
98 return fmt.Errorf("checkout back to %s: %w", s.DefaultBranch, errCheckout)
99 }
100 if err != nil {
101 return fmt.Errorf("open radicle patch: %w", err)
102 }
103
104 m := patchOpenedRe.FindStringSubmatch(out)
105 if m == nil {
106 return fmt.Errorf("could not parse patch id from: %q", out)
107 }
108
109 return s.State.Upsert(s.RepoPair, state.ItemMapping{
110 Kind: "patch",
111 ForgejoID: pr.Index,
112 RadicleID: m[1],
113 ContentHash: hashText(pr.Title, pr.Body),
114 })
115}
116
117func (s *PatchSyncer) mirrorRadicleToForgejo(p radicle.Patch) error {
118 existing, err := s.State.FindByRadicleID(s.RepoPair, "patch", p.ID)
119 if err != nil {
120 return err
121 }
122 if existing != nil {
123 return nil
124 }
125 if len(p.Revisions) == 0 {
126 return fmt.Errorf("patch has no revisions")
127 }
128 latest := p.Revisions[len(p.Revisions)-1]
129
130 branch := localPatchBranch(p.ID)
131 if _, err := s.git("fetch", "-q", "rad", latest.OID); err != nil {
132 return fmt.Errorf("fetch patch revision: %w", err)
133 }
134 if _, err := s.git("branch", "-q", "-f", branch, "FETCH_HEAD"); err != nil {
135 return fmt.Errorf("branch patch revision: %w", err)
136 }
137 if _, err := s.git("push", "-q", "forgejo", branch+":refs/heads/"+branch); err != nil {
138 return fmt.Errorf("push branch to forgejo: %w", err)
139 }
140
141 pr, err := s.Forgejo.CreatePullRequest(p.Title, latest.Description+mirrorNotice, branch, s.DefaultBranch)
142 if err != nil {
143 return fmt.Errorf("create forgejo pull request: %w", err)
144 }
145
146 return s.State.Upsert(s.RepoPair, state.ItemMapping{
147 Kind: "patch",
148 ForgejoID: pr.Index,
149 RadicleID: p.ID,
150 ContentHash: hashText(p.Title, latest.Description),
151 })
152}
153
154func localPRBranch(index int64) string {
155 return fmt.Sprintf("sync/forgejo-pr-%d", index)
156}
157
158func localPatchBranch(radicleID string) string {
159 short := radicleID
160 if len(short) > 12 {
161 short = short[:12]
162 }
163 return "sync/radicle-patch-" + strings.ToLower(short)
164}