Federated via graft
9.5 kB
306 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 gitFF *GitSyncerFF // set instead of git when pair.ForgejoMirror is used
24 issues *IssueSyncer
25 patches *PatchSyncer
26}
27
28// New builds a RepoSyncer for one configured pair. workDir is where this
29// pair's local git mirror is kept, e.g. <state-dir>/<pair-name>.
30func New(pair config.RepoPair, st *state.Store, workDir string) (*RepoSyncer, error) {
31 token, err := config.ReadToken(pair.Forgejo.TokenFile)
32 if err != nil {
33 return nil, err
34 }
35 fc := forgejo.New(pair.Forgejo.BaseURL, pair.Forgejo.Owner, pair.Forgejo.Repo, token)
36
37 repo, err := fc.GetRepository()
38 if err != nil {
39 return nil, fmt.Errorf("resolve forgejo repository: %w", err)
40 }
41
42 rs := &RepoSyncer{pair: pair, forgejo: fc}
43
44 forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo
45
46 series := pair.Series
47 if series == "" {
48 series = pair.Name
49 }
50
51 var bluesky *BlueskyPoster
52 if pair.Bluesky != nil {
53 appPassword, err := config.ReadToken(pair.Bluesky.AppPasswordFile)
54 if err != nil {
55 return nil, fmt.Errorf("read bluesky app password: %w", err)
56 }
57 bluesky = &BlueskyPoster{
58 Handle: pair.Bluesky.Handle,
59 AppPassword: appPassword,
60 PDSHost: pair.Bluesky.PDSHost,
61 }
62 }
63
64 if pair.ForgejoMirror != nil {
65 mirrorToken, err := config.ReadToken(pair.ForgejoMirror.TokenFile)
66 if err != nil {
67 return nil, err
68 }
69 mfc := forgejo.New(pair.ForgejoMirror.BaseURL, pair.ForgejoMirror.Owner, pair.ForgejoMirror.Repo, mirrorToken)
70 mrepo, err := mfc.GetRepository()
71 if err != nil {
72 return nil, fmt.Errorf("resolve forgejo_mirror repository: %w", err)
73 }
74 mirrorWebURL := strings.TrimRight(pair.ForgejoMirror.BaseURL, "/") + "/" + pair.ForgejoMirror.Owner + "/" + pair.ForgejoMirror.Repo
75
76 if pair.Sync.Git {
77 rs.gitFF = &GitSyncerFF{
78 RepoPair: pair.Name,
79 WorkDir: workDir,
80 AURL: authenticatedCloneURL(repo.CloneURL, token),
81 AWebURL: forgejoWebURL,
82 BURL: authenticatedCloneURL(mrepo.CloneURL, mirrorToken),
83 BWebURL: mirrorWebURL,
84 DefaultBranch: repo.DefaultBranch,
85 State: st,
86 Series: series,
87 SeriesURL: forgejoWebURL,
88 Bluesky: bluesky,
89 }
90 }
91 return rs, nil
92 }
93
94 rc := radicle.New(pair.Radicle.HTTPBaseURL, pair.Radicle.RID, pair.Radicle.RadHome, "rad")
95 rs.radicle = rc
96
97 radicleWebURL := RadicleExplorerLink(*pair.Radicle)
98 seriesURL := radicleWebURL
99 if seriesURL == "" {
100 seriesURL = forgejoWebURL
101 }
102
103 if pair.Sync.Git || pair.Sync.Patches {
104 rs.git = &GitSyncer{
105 RepoPair: pair.Name,
106 WorkDir: workDir,
107 ForgejoURL: authenticatedCloneURL(repo.CloneURL, token),
108 ForgejoWebURL: forgejoWebURL,
109 RadicleWebURL: radicleWebURL,
110 ForgejoBranch: repo.DefaultBranch,
111 RID: pair.Radicle.RID,
112 RadHome: pair.Radicle.RadHome,
113 State: st,
114 Series: series,
115 SeriesURL: seriesURL,
116 Bluesky: bluesky,
117 }
118 }
119 if pair.Sync.Issues {
120 rs.issues = &IssueSyncer{
121 RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st,
122 ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL,
123 Series: series, SeriesURL: seriesURL,
124 }
125 }
126 if pair.Sync.Patches {
127 rs.patches = &PatchSyncer{
128 RepoPair: pair.Name,
129 WorkDir: workDir,
130 RadHome: pair.Radicle.RadHome,
131 DefaultBranch: repo.DefaultBranch,
132 Forgejo: fc,
133 Radicle: rc,
134 State: st,
135 ForgejoWebURL: forgejoWebURL,
136 RadicleWebURL: radicleWebURL,
137 Series: series,
138 SeriesURL: seriesURL,
139 Bluesky: bluesky,
140 }
141 }
142 return rs, nil
143}
144
145// RadicleExplorerLink builds the base URL for "view this on Radicle" links:
146// <explorer>/nodes/<httpd-host>/<rid>. Empty if no explorer_url is
147// configured, so templates can skip the link entirely.
148func RadicleExplorerLink(rt config.RadicleTarget) string {
149 if rt.ExplorerURL == "" {
150 return ""
151 }
152 host := rt.HTTPBaseURL
153 if u, err := url.Parse(rt.HTTPBaseURL); err == nil && u.Host != "" {
154 host = u.Host
155 }
156 return strings.TrimRight(rt.ExplorerURL, "/") + "/nodes/" + host + "/" + rt.RID
157}
158
159// Name is this pair's name, as given in the config.
160func (rs *RepoSyncer) Name() string { return rs.pair.Name }
161
162// ForgejoClient exposes this pair's Forgejo client for cross-pair
163// detection helpers (see HasAuthorizedIntegration) that need to make API
164// calls outside the normal sync flow.
165func (rs *RepoSyncer) ForgejoClient() *forgejo.Client { return rs.forgejo }
166
167// ForgejoHost is the host this pair's Forgejo repo lives on, e.g.
168// "f1.cyberwild.org".
169func (rs *RepoSyncer) ForgejoHost() string {
170 u, err := url.Parse(rs.pair.Forgejo.BaseURL)
171 if err != nil {
172 return ""
173 }
174 return u.Host
175}
176
177// SetAuthorizedIntegrationSource declares that another pair's Forgejo host
178// already pushes directly into this pair's Forgejo via Authorized
179// Integrations (see authint.go and GitSyncer.AuthorizedIntegrationSource).
180// Set once, after every pair in a series is known, from cmd/sync/main.go —
181// a single pair's own construction can't see its siblings.
182func (rs *RepoSyncer) SetAuthorizedIntegrationSource(host string) {
183 if rs.git != nil {
184 rs.git.AuthorizedIntegrationSource = host
185 }
186 if rs.gitFF != nil {
187 rs.gitFF.AuthorizedIntegrationSource = host
188 }
189}
190
191// HasAuthorizedIntegrationSource reports whether this pair's Forgejo side
192// already receives pushes directly from another pair's Forgejo via
193// Authorized Integrations (set by SetAuthorizedIntegrationSource at
194// startup) — used by the dashboard to explain why this side shows no
195// graft-logged events even though it holds the mirrored content.
196func (rs *RepoSyncer) HasAuthorizedIntegrationSource() bool {
197 if rs.git != nil && rs.git.AuthorizedIntegrationSource != "" {
198 return true
199 }
200 return rs.gitFF != nil && rs.gitFF.AuthorizedIntegrationSource != ""
201}
202
203// Series is the dashboard row this pair's activity groups under: its
204// configured series, or its own name if unset.
205func (rs *RepoSyncer) Series() string {
206 if rs.pair.Series != "" {
207 return rs.pair.Series
208 }
209 return rs.pair.Name
210}
211
212// CommentOnItem posts a reply (from the ActivityPub comment bridge, see
213// internal/activitypub) onto the underlying Forgejo issue/PR and Radicle
214// issue/patch this pair mirrors. For a patch, radicleID is used as the
215// revision to comment on — correct for the common single-revision case,
216// since a patch's own id equals its first revision's id; a patch that has
217// since been updated with further revisions may have the comment attach
218// to an older revision instead of the latest one, a known scoped
219// limitation rather than a bug to chase down for v2.
220func (rs *RepoSyncer) CommentOnItem(kind string, forgejoID int64, radicleID, body string) error {
221 var errs []error
222 if forgejoID != 0 {
223 if err := rs.forgejo.CreateIssueComment(forgejoID, body); err != nil {
224 errs = append(errs, fmt.Errorf("forgejo: %w", err))
225 }
226 }
227 if radicleID != "" {
228 var err error
229 switch kind {
230 case "issue":
231 err = rs.radicle.CommentIssue(radicleID, body)
232 case "patch":
233 err = rs.radicle.CommentPatch(radicleID, body)
234 default:
235 err = fmt.Errorf("cannot comment on kind %q", kind)
236 }
237 if err != nil {
238 errs = append(errs, fmt.Errorf("radicle: %w", err))
239 }
240 }
241 return errors.Join(errs...)
242}
243
244// RadicleDID returns the local Radicle node identity this pair pushes as,
245// resolved the same way GitSyncer resolves it for remote setup (`rad self
246// --nid`). Empty if this pair has no git sync configured, so nothing to
247// report.
248func (rs *RepoSyncer) RadicleDID() (string, error) {
249 if rs.git == nil {
250 return "", nil
251 }
252 nid, err := rs.git.localNodeID()
253 if err != nil {
254 return "", err
255 }
256 if nid == "" {
257 return "", nil
258 }
259 return "did:key:" + nid, nil
260}
261
262// Run executes every enabled scope once, logging and continuing past
263// per-scope errors so one broken scope doesn't block the others. It
264// returns each scope's error (nil if disabled or successful) for the
265// caller to report via internal/status.
266func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) {
267 log = log.With("pair", rs.pair.Name)
268
269 if rs.git != nil {
270 if gitErr = rs.git.Sync(); gitErr != nil {
271 log.Error("git sync failed", "err", gitErr)
272 }
273 }
274 if rs.gitFF != nil {
275 if gitErr = rs.gitFF.Sync(); gitErr != nil {
276 log.Error("git sync failed", "err", gitErr)
277 }
278 }
279 if rs.issues != nil {
280 if issuesErr = rs.issues.Sync(); issuesErr != nil {
281 log.Error("issue sync failed", "err", issuesErr)
282 }
283 }
284 if rs.patches != nil {
285 if patchErr = rs.patches.Sync(); patchErr != nil {
286 log.Error("patch sync failed", "err", patchErr)
287 }
288 }
289 return gitErr, issuesErr, patchErr
290}
291
292// authenticatedCloneURL embeds a token into an HTTPS clone URL the way
293// Forgejo expects (token as the basic-auth username).
294func authenticatedCloneURL(cloneURL, token string) string {
295 u, err := url.Parse(cloneURL)
296 if err != nil {
297 return cloneURL
298 }
299 u.User = url.User(token)
300 return u.String()
301}
302
303// WorkDirFor returns the per-pair git mirror directory under stateDir.
304func WorkDirFor(stateDir, pairName string) string {
305 return filepath.Join(stateDir, "repos", pairName)
306}