Federated via graft
6.3 kB
212 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 if pair.Sync.Git || pair.Sync.Patches {
58 rs.git = &GitSyncer{
59 RepoPair: pair.Name,
60 WorkDir: workDir,
61 ForgejoURL: authenticatedCloneURL(repo.CloneURL, token),
62 ForgejoWebURL: forgejoWebURL,
63 RadicleWebURL: radicleWebURL,
64 ForgejoBranch: repo.DefaultBranch,
65 RID: pair.Radicle.RID,
66 RadHome: pair.Radicle.RadHome,
67 State: st,
68 Series: series,
69 SeriesURL: seriesURL,
70 }
71 }
72 if pair.Sync.Issues {
73 rs.issues = &IssueSyncer{
74 RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st,
75 ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL,
76 Series: series, SeriesURL: seriesURL,
77 }
78 }
79 if pair.Sync.Patches {
80 rs.patches = &PatchSyncer{
81 RepoPair: pair.Name,
82 WorkDir: workDir,
83 RadHome: pair.Radicle.RadHome,
84 DefaultBranch: repo.DefaultBranch,
85 Forgejo: fc,
86 Radicle: rc,
87 State: st,
88 ForgejoWebURL: forgejoWebURL,
89 RadicleWebURL: radicleWebURL,
90 Series: series,
91 SeriesURL: seriesURL,
92 }
93 }
94 return rs, nil
95}
96
97// RadicleExplorerLink builds the base URL for "view this on Radicle" links:
98// <explorer>/nodes/<httpd-host>/<rid>. Empty if no explorer_url is
99// configured, so templates can skip the link entirely.
100func RadicleExplorerLink(rt config.RadicleTarget) string {
101 if rt.ExplorerURL == "" {
102 return ""
103 }
104 host := rt.HTTPBaseURL
105 if u, err := url.Parse(rt.HTTPBaseURL); err == nil && u.Host != "" {
106 host = u.Host
107 }
108 return strings.TrimRight(rt.ExplorerURL, "/") + "/nodes/" + host + "/" + rt.RID
109}
110
111// Name is this pair's name, as given in the config.
112func (rs *RepoSyncer) Name() string { return rs.pair.Name }
113
114// Series is the dashboard row this pair's activity groups under: its
115// configured series, or its own name if unset.
116func (rs *RepoSyncer) Series() string {
117 if rs.pair.Series != "" {
118 return rs.pair.Series
119 }
120 return rs.pair.Name
121}
122
123// CommentOnItem posts a reply (from the ActivityPub comment bridge, see
124// internal/activitypub) onto the underlying Forgejo issue/PR and Radicle
125// issue/patch this pair mirrors. For a patch, radicleID is used as the
126// revision to comment on — correct for the common single-revision case,
127// since a patch's own id equals its first revision's id; a patch that has
128// since been updated with further revisions may have the comment attach
129// to an older revision instead of the latest one, a known scoped
130// limitation rather than a bug to chase down for v2.
131func (rs *RepoSyncer) CommentOnItem(kind string, forgejoID int64, radicleID, body string) error {
132 var errs []error
133 if forgejoID != 0 {
134 if err := rs.forgejo.CreateIssueComment(forgejoID, body); err != nil {
135 errs = append(errs, fmt.Errorf("forgejo: %w", err))
136 }
137 }
138 if radicleID != "" {
139 var err error
140 switch kind {
141 case "issue":
142 err = rs.radicle.CommentIssue(radicleID, body)
143 case "patch":
144 err = rs.radicle.CommentPatch(radicleID, body)
145 default:
146 err = fmt.Errorf("cannot comment on kind %q", kind)
147 }
148 if err != nil {
149 errs = append(errs, fmt.Errorf("radicle: %w", err))
150 }
151 }
152 return errors.Join(errs...)
153}
154
155// RadicleDID returns the local Radicle node identity this pair pushes as,
156// resolved the same way GitSyncer resolves it for remote setup (`rad self
157// --nid`). Empty if this pair has no git sync configured, so nothing to
158// report.
159func (rs *RepoSyncer) RadicleDID() (string, error) {
160 if rs.git == nil {
161 return "", nil
162 }
163 nid, err := rs.git.localNodeID()
164 if err != nil {
165 return "", err
166 }
167 if nid == "" {
168 return "", nil
169 }
170 return "did:key:" + nid, nil
171}
172
173// Run executes every enabled scope once, logging and continuing past
174// per-scope errors so one broken scope doesn't block the others. It
175// returns each scope's error (nil if disabled or successful) for the
176// caller to report via internal/status.
177func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) {
178 log = log.With("pair", rs.pair.Name)
179
180 if rs.git != nil {
181 if gitErr = rs.git.Sync(); gitErr != nil {
182 log.Error("git sync failed", "err", gitErr)
183 }
184 }
185 if rs.issues != nil {
186 if issuesErr = rs.issues.Sync(); issuesErr != nil {
187 log.Error("issue sync failed", "err", issuesErr)
188 }
189 }
190 if rs.patches != nil {
191 if patchErr = rs.patches.Sync(); patchErr != nil {
192 log.Error("patch sync failed", "err", patchErr)
193 }
194 }
195 return gitErr, issuesErr, patchErr
196}
197
198// authenticatedCloneURL embeds a token into an HTTPS clone URL the way
199// Forgejo expects (token as the basic-auth username).
200func authenticatedCloneURL(cloneURL, token string) string {
201 u, err := url.Parse(cloneURL)
202 if err != nil {
203 return cloneURL
204 }
205 u.User = url.User(token)
206 return u.String()
207}
208
209// WorkDirFor returns the per-pair git mirror directory under stateDir.
210func WorkDirFor(stateDir, pairName string) string {
211 return filepath.Join(stateDir, "repos", pairName)
212}