Federated via graft
6.7 kB
227 lines
1package sync
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "net/url"
8 "path/filepath"
9 "strings"
10
11 "graft/internal/config"
12 "graft/internal/forgejo"
13 "graft/internal/radicle"
14 "graft/internal/state"
15)
16
17// RepoSyncer runs one repo pair's enabled sync scopes for one pass.
18type RepoSyncer struct {
19 pair config.RepoPair
20 forgejo *forgejo.Client
21 radicle *radicle.Client
22 git *GitSyncer
23 issues *IssueSyncer
24 patches *PatchSyncer
25}
26
27// New builds a RepoSyncer for one configured pair. workDir is where this
28// pair's local git mirror is kept, e.g. <state-dir>/<pair-name>.
29func New(pair config.RepoPair, st *state.Store, workDir string) (*RepoSyncer, error) {
30 token, err := config.ReadToken(pair.Forgejo.TokenFile)
31 if err != nil {
32 return nil, err
33 }
34 fc := forgejo.New(pair.Forgejo.BaseURL, pair.Forgejo.Owner, pair.Forgejo.Repo, token)
35
36 repo, err := fc.GetRepository()
37 if err != nil {
38 return nil, fmt.Errorf("resolve forgejo repository: %w", err)
39 }
40
41 rc := radicle.New(pair.Radicle.HTTPBaseURL, pair.Radicle.RID, pair.Radicle.RadHome, "rad")
42
43 rs := &RepoSyncer{pair: pair, forgejo: fc, radicle: rc}
44
45 forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo
46 radicleWebURL := RadicleExplorerLink(pair.Radicle)
47
48 series := pair.Series
49 if series == "" {
50 series = pair.Name
51 }
52 seriesURL := radicleWebURL
53 if seriesURL == "" {
54 seriesURL = forgejoWebURL
55 }
56
57 var bluesky *BlueskyPoster
58 if pair.Bluesky != nil {
59 appPassword, err := config.ReadToken(pair.Bluesky.AppPasswordFile)
60 if err != nil {
61 return nil, fmt.Errorf("read bluesky app password: %w", err)
62 }
63 bluesky = &BlueskyPoster{
64 Handle: pair.Bluesky.Handle,
65 AppPassword: appPassword,
66 PDSHost: pair.Bluesky.PDSHost,
67 }
68 }
69
70 if pair.Sync.Git || pair.Sync.Patches {
71 rs.git = &GitSyncer{
72 RepoPair: pair.Name,
73 WorkDir: workDir,
74 ForgejoURL: authenticatedCloneURL(repo.CloneURL, token),
75 ForgejoWebURL: forgejoWebURL,
76 RadicleWebURL: radicleWebURL,
77 ForgejoBranch: repo.DefaultBranch,
78 RID: pair.Radicle.RID,
79 RadHome: pair.Radicle.RadHome,
80 State: st,
81 Series: series,
82 SeriesURL: seriesURL,
83 Bluesky: bluesky,
84 }
85 }
86 if pair.Sync.Issues {
87 rs.issues = &IssueSyncer{
88 RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st,
89 ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL,
90 Series: series, SeriesURL: seriesURL,
91 }
92 }
93 if pair.Sync.Patches {
94 rs.patches = &PatchSyncer{
95 RepoPair: pair.Name,
96 WorkDir: workDir,
97 RadHome: pair.Radicle.RadHome,
98 DefaultBranch: repo.DefaultBranch,
99 Forgejo: fc,
100 Radicle: rc,
101 State: st,
102 ForgejoWebURL: forgejoWebURL,
103 RadicleWebURL: radicleWebURL,
104 Series: series,
105 SeriesURL: seriesURL,
106 Bluesky: bluesky,
107 }
108 }
109 return rs, nil
110}
111
112// RadicleExplorerLink builds the base URL for "view this on Radicle" links:
113// <explorer>/nodes/<httpd-host>/<rid>. Empty if no explorer_url is
114// configured, so templates can skip the link entirely.
115func RadicleExplorerLink(rt config.RadicleTarget) string {
116 if rt.ExplorerURL == "" {
117 return ""
118 }
119 host := rt.HTTPBaseURL
120 if u, err := url.Parse(rt.HTTPBaseURL); err == nil && u.Host != "" {
121 host = u.Host
122 }
123 return strings.TrimRight(rt.ExplorerURL, "/") + "/nodes/" + host + "/" + rt.RID
124}
125
126// Name is this pair's name, as given in the config.
127func (rs *RepoSyncer) Name() string { return rs.pair.Name }
128
129// Series is the dashboard row this pair's activity groups under: its
130// configured series, or its own name if unset.
131func (rs *RepoSyncer) Series() string {
132 if rs.pair.Series != "" {
133 return rs.pair.Series
134 }
135 return rs.pair.Name
136}
137
138// CommentOnItem posts a reply (from the ActivityPub comment bridge, see
139// internal/activitypub) onto the underlying Forgejo issue/PR and Radicle
140// issue/patch this pair mirrors. For a patch, radicleID is used as the
141// revision to comment on — correct for the common single-revision case,
142// since a patch's own id equals its first revision's id; a patch that has
143// since been updated with further revisions may have the comment attach
144// to an older revision instead of the latest one, a known scoped
145// limitation rather than a bug to chase down for v2.
146func (rs *RepoSyncer) CommentOnItem(kind string, forgejoID int64, radicleID, body string) error {
147 var errs []error
148 if forgejoID != 0 {
149 if err := rs.forgejo.CreateIssueComment(forgejoID, body); err != nil {
150 errs = append(errs, fmt.Errorf("forgejo: %w", err))
151 }
152 }
153 if radicleID != "" {
154 var err error
155 switch kind {
156 case "issue":
157 err = rs.radicle.CommentIssue(radicleID, body)
158 case "patch":
159 err = rs.radicle.CommentPatch(radicleID, body)
160 default:
161 err = fmt.Errorf("cannot comment on kind %q", kind)
162 }
163 if err != nil {
164 errs = append(errs, fmt.Errorf("radicle: %w", err))
165 }
166 }
167 return errors.Join(errs...)
168}
169
170// RadicleDID returns the local Radicle node identity this pair pushes as,
171// resolved the same way GitSyncer resolves it for remote setup (`rad self
172// --nid`). Empty if this pair has no git sync configured, so nothing to
173// report.
174func (rs *RepoSyncer) RadicleDID() (string, error) {
175 if rs.git == nil {
176 return "", nil
177 }
178 nid, err := rs.git.localNodeID()
179 if err != nil {
180 return "", err
181 }
182 if nid == "" {
183 return "", nil
184 }
185 return "did:key:" + nid, nil
186}
187
188// Run executes every enabled scope once, logging and continuing past
189// per-scope errors so one broken scope doesn't block the others. It
190// returns each scope's error (nil if disabled or successful) for the
191// caller to report via internal/status.
192func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) {
193 log = log.With("pair", rs.pair.Name)
194
195 if rs.git != nil {
196 if gitErr = rs.git.Sync(); gitErr != nil {
197 log.Error("git sync failed", "err", gitErr)
198 }
199 }
200 if rs.issues != nil {
201 if issuesErr = rs.issues.Sync(); issuesErr != nil {
202 log.Error("issue sync failed", "err", issuesErr)
203 }
204 }
205 if rs.patches != nil {
206 if patchErr = rs.patches.Sync(); patchErr != nil {
207 log.Error("patch sync failed", "err", patchErr)
208 }
209 }
210 return gitErr, issuesErr, patchErr
211}
212
213// authenticatedCloneURL embeds a token into an HTTPS clone URL the way
214// Forgejo expects (token as the basic-auth username).
215func authenticatedCloneURL(cloneURL, token string) string {
216 u, err := url.Parse(cloneURL)
217 if err != nil {
218 return cloneURL
219 }
220 u.User = url.User(token)
221 return u.String()
222}
223
224// WorkDirFor returns the per-pair git mirror directory under stateDir.
225func WorkDirFor(stateDir, pairName string) string {
226 return filepath.Join(stateDir, "repos", pairName)
227}