Federated via graft
6.0 kB
186 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 ForgejoWebURL string // https://host/owner/repo, for links in the UI
28 RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI
29 Series string // dashboard row this pair's activity groups under
30 SeriesURL string // where clicking that row's name goes
31}
32
33func (s *PatchSyncer) git(args ...string) (string, error) {
34 cmd := exec.Command("git", args...)
35 cmd.Dir = s.WorkDir
36 // Must start from the inherited environment, not a bare slice: git
37 // needs PATH to resolve the git-remote-rad helper for rad:// remotes.
38 cmd.Env = append(os.Environ(), "RAD_HOME="+s.RadHome)
39 var out bytes.Buffer
40 cmd.Stdout = &out
41 cmd.Stderr = &out
42 if err := cmd.Run(); err != nil {
43 return out.String(), fmt.Errorf("git %v: %w: %s", args, err, out.String())
44 }
45 return out.String(), nil
46}
47
48func (s *PatchSyncer) Sync() error {
49 prs, err := s.Forgejo.ListPullRequests()
50 if err != nil {
51 return fmt.Errorf("list forgejo pull requests: %w", err)
52 }
53 patches, err := s.Radicle.ListPatches()
54 if err != nil {
55 return fmt.Errorf("list radicle patches: %w", err)
56 }
57
58 for _, pr := range prs {
59 if err := s.mirrorForgejoToRadicle(pr); err != nil {
60 return fmt.Errorf("mirror forgejo PR #%d: %w", pr.Index, err)
61 }
62 }
63 for _, p := range patches {
64 if err := s.mirrorRadicleToForgejo(p); err != nil {
65 return fmt.Errorf("mirror radicle patch %s: %w", p.ID, err)
66 }
67 }
68 return nil
69}
70
71var patchOpenedRe = regexp.MustCompile(`Patch ([0-9a-f]{40}) opened`)
72
73func (s *PatchSyncer) mirrorForgejoToRadicle(pr forgejo.PullRequest) error {
74 existing, err := s.State.FindByForgejoID(s.RepoPair, "patch", pr.Index)
75 if err != nil {
76 return err
77 }
78 if existing != nil {
79 return nil
80 }
81
82 // Bring the PR's head branch content into the local clone, then open a
83 // patch from it via the pseudo-ref, exactly as verified interactively:
84 // `git push rad HEAD:refs/patches`.
85 if _, err := s.git("fetch", "-q", "forgejo", pr.Head.Ref); err != nil {
86 return fmt.Errorf("fetch PR head: %w", err)
87 }
88 if _, err := s.git("checkout", "-q", "-B", localPRBranch(pr.Index), "FETCH_HEAD"); err != nil {
89 return fmt.Errorf("checkout PR head: %w", err)
90 }
91 // git push -o values cannot contain newlines, unlike issue/PR bodies.
92 message := pr.Title + " (mirrored automatically, do not edit here)"
93 out, err := s.git("push", "rad", "-o", "patch.message="+message, "HEAD:refs/patches")
94 // The working clone's mirroring never touches local branch refs (only
95 // FETCH_HEAD and the remotes), so there may be no local default-branch
96 // ref to switch back to yet: fetch and (re)create it rather than assume
97 // it exists.
98 if _, errFetch := s.git("fetch", "-q", "forgejo", s.DefaultBranch); errFetch != nil {
99 return fmt.Errorf("fetch %s to restore: %w", s.DefaultBranch, errFetch)
100 }
101 if _, errCheckout := s.git("checkout", "-q", "-B", s.DefaultBranch, "FETCH_HEAD"); errCheckout != nil {
102 return fmt.Errorf("checkout back to %s: %w", s.DefaultBranch, errCheckout)
103 }
104 if err != nil {
105 return fmt.Errorf("open radicle patch: %w", err)
106 }
107
108 m := patchOpenedRe.FindStringSubmatch(out)
109 if m == nil {
110 return fmt.Errorf("could not parse patch id from: %q", out)
111 }
112
113 if err := s.State.Upsert(s.RepoPair, state.ItemMapping{
114 Kind: "patch",
115 ForgejoID: pr.Index,
116 RadicleID: m[1],
117 ContentHash: hashText(pr.Title, pr.Body),
118 }); err != nil {
119 return err
120 }
121 s.State.LogActivity(state.Activity{
122 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
123 Kind: "patch", Direction: state.ForgejoToRadicle, Summary: pr.Title,
124 URL: s.RadicleWebURL + "/patches/" + m[1],
125 ForgejoID: pr.Index, RadicleID: m[1],
126 })
127 return nil
128}
129
130func (s *PatchSyncer) mirrorRadicleToForgejo(p radicle.Patch) error {
131 existing, err := s.State.FindByRadicleID(s.RepoPair, "patch", p.ID)
132 if err != nil {
133 return err
134 }
135 if existing != nil {
136 return nil
137 }
138 if len(p.Revisions) == 0 {
139 return fmt.Errorf("patch has no revisions")
140 }
141 latest := p.Revisions[len(p.Revisions)-1]
142
143 branch := localPatchBranch(p.ID)
144 if _, err := s.git("fetch", "-q", "rad", latest.OID); err != nil {
145 return fmt.Errorf("fetch patch revision: %w", err)
146 }
147 if _, err := s.git("branch", "-q", "-f", branch, "FETCH_HEAD"); err != nil {
148 return fmt.Errorf("branch patch revision: %w", err)
149 }
150 if _, err := s.git("push", "-q", "forgejo", branch+":refs/heads/"+branch); err != nil {
151 return fmt.Errorf("push branch to forgejo: %w", err)
152 }
153
154 pr, err := s.Forgejo.CreatePullRequest(p.Title, latest.Description+mirrorNotice, branch, s.DefaultBranch)
155 if err != nil {
156 return fmt.Errorf("create forgejo pull request: %w", err)
157 }
158
159 if err := s.State.Upsert(s.RepoPair, state.ItemMapping{
160 Kind: "patch",
161 ForgejoID: pr.Index,
162 RadicleID: p.ID,
163 ContentHash: hashText(p.Title, latest.Description),
164 }); err != nil {
165 return err
166 }
167 s.State.LogActivity(state.Activity{
168 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
169 Kind: "patch", Direction: state.RadicleToForgejo, Summary: p.Title,
170 URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index),
171 ForgejoID: pr.Index, RadicleID: p.ID,
172 })
173 return nil
174}
175
176func localPRBranch(index int64) string {
177 return fmt.Sprintf("sync/forgejo-pr-%d", index)
178}
179
180func localPatchBranch(radicleID string) string {
181 short := radicleID
182 if len(short) > 12 {
183 short = short[:12]
184 }
185 return "sync/radicle-patch-" + strings.ToLower(short)
186}