Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

graft-source / internal / sync / syncer.go
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}