Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

graft-source / internal / sync / patches.go
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}