Federated via graft
5.8 kB
194 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// Run executes every enabled scope once, logging and continuing past
156// per-scope errors so one broken scope doesn't block the others. It
157// returns each scope's error (nil if disabled or successful) for the
158// caller to report via internal/status.
159func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) {
160 log = log.With("pair", rs.pair.Name)
161
162 if rs.git != nil {
163 if gitErr = rs.git.Sync(); gitErr != nil {
164 log.Error("git sync failed", "err", gitErr)
165 }
166 }
167 if rs.issues != nil {
168 if issuesErr = rs.issues.Sync(); issuesErr != nil {
169 log.Error("issue sync failed", "err", issuesErr)
170 }
171 }
172 if rs.patches != nil {
173 if patchErr = rs.patches.Sync(); patchErr != nil {
174 log.Error("patch sync failed", "err", patchErr)
175 }
176 }
177 return gitErr, issuesErr, patchErr
178}
179
180// authenticatedCloneURL embeds a token into an HTTPS clone URL the way
181// Forgejo expects (token as the basic-auth username).
182func authenticatedCloneURL(cloneURL, token string) string {
183 u, err := url.Parse(cloneURL)
184 if err != nil {
185 return cloneURL
186 }
187 u.User = url.User(token)
188 return u.String()
189}
190
191// WorkDirFor returns the per-pair git mirror directory under stateDir.
192func WorkDirFor(stateDir, pairName string) string {
193 return filepath.Join(stateDir, "repos", pairName)
194}