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