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.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}