Federated via graft
7.7 kB
253 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// ForgejoClient exposes this pair's Forgejo client for cross-pair
130// detection helpers (see HasAuthorizedIntegration) that need to make API
131// calls outside the normal sync flow.
132func (rs *RepoSyncer) ForgejoClient() *forgejo.Client { return rs.forgejo }
133
134// ForgejoHost is the host this pair's Forgejo repo lives on, e.g.
135// "f1.cyberwild.org".
136func (rs *RepoSyncer) ForgejoHost() string {
137 u, err := url.Parse(rs.pair.Forgejo.BaseURL)
138 if err != nil {
139 return ""
140 }
141 return u.Host
142}
143
144// SetAuthorizedIntegrationSource declares that another pair's Forgejo host
145// already pushes directly into this pair's Forgejo via Authorized
146// Integrations (see authint.go and GitSyncer.AuthorizedIntegrationSource).
147// Set once, after every pair in a series is known, from cmd/sync/main.go —
148// a single pair's own construction can't see its siblings.
149func (rs *RepoSyncer) SetAuthorizedIntegrationSource(host string) {
150 if rs.git != nil {
151 rs.git.AuthorizedIntegrationSource = host
152 }
153}
154
155// Series is the dashboard row this pair's activity groups under: its
156// configured series, or its own name if unset.
157func (rs *RepoSyncer) Series() string {
158 if rs.pair.Series != "" {
159 return rs.pair.Series
160 }
161 return rs.pair.Name
162}
163
164// CommentOnItem posts a reply (from the ActivityPub comment bridge, see
165// internal/activitypub) onto the underlying Forgejo issue/PR and Radicle
166// issue/patch this pair mirrors. For a patch, radicleID is used as the
167// revision to comment on — correct for the common single-revision case,
168// since a patch's own id equals its first revision's id; a patch that has
169// since been updated with further revisions may have the comment attach
170// to an older revision instead of the latest one, a known scoped
171// limitation rather than a bug to chase down for v2.
172func (rs *RepoSyncer) CommentOnItem(kind string, forgejoID int64, radicleID, body string) error {
173 var errs []error
174 if forgejoID != 0 {
175 if err := rs.forgejo.CreateIssueComment(forgejoID, body); err != nil {
176 errs = append(errs, fmt.Errorf("forgejo: %w", err))
177 }
178 }
179 if radicleID != "" {
180 var err error
181 switch kind {
182 case "issue":
183 err = rs.radicle.CommentIssue(radicleID, body)
184 case "patch":
185 err = rs.radicle.CommentPatch(radicleID, body)
186 default:
187 err = fmt.Errorf("cannot comment on kind %q", kind)
188 }
189 if err != nil {
190 errs = append(errs, fmt.Errorf("radicle: %w", err))
191 }
192 }
193 return errors.Join(errs...)
194}
195
196// RadicleDID returns the local Radicle node identity this pair pushes as,
197// resolved the same way GitSyncer resolves it for remote setup (`rad self
198// --nid`). Empty if this pair has no git sync configured, so nothing to
199// report.
200func (rs *RepoSyncer) RadicleDID() (string, error) {
201 if rs.git == nil {
202 return "", nil
203 }
204 nid, err := rs.git.localNodeID()
205 if err != nil {
206 return "", err
207 }
208 if nid == "" {
209 return "", nil
210 }
211 return "did:key:" + nid, nil
212}
213
214// Run executes every enabled scope once, logging and continuing past
215// per-scope errors so one broken scope doesn't block the others. It
216// returns each scope's error (nil if disabled or successful) for the
217// caller to report via internal/status.
218func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) {
219 log = log.With("pair", rs.pair.Name)
220
221 if rs.git != nil {
222 if gitErr = rs.git.Sync(); gitErr != nil {
223 log.Error("git sync failed", "err", gitErr)
224 }
225 }
226 if rs.issues != nil {
227 if issuesErr = rs.issues.Sync(); issuesErr != nil {
228 log.Error("issue sync failed", "err", issuesErr)
229 }
230 }
231 if rs.patches != nil {
232 if patchErr = rs.patches.Sync(); patchErr != nil {
233 log.Error("patch sync failed", "err", patchErr)
234 }
235 }
236 return gitErr, issuesErr, patchErr
237}
238
239// authenticatedCloneURL embeds a token into an HTTPS clone URL the way
240// Forgejo expects (token as the basic-auth username).
241func authenticatedCloneURL(cloneURL, token string) string {
242 u, err := url.Parse(cloneURL)
243 if err != nil {
244 return cloneURL
245 }
246 u.User = url.User(token)
247 return u.String()
248}
249
250// WorkDirFor returns the per-pair git mirror directory under stateDir.
251func WorkDirFor(stateDir, pairName string) string {
252 return filepath.Join(stateDir, "repos", pairName)
253}