Federated via graft
0

Configure Feed

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

graft-source / internal / sync / syncer.go
8.2 kB 262 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// HasAuthorizedIntegrationSource reports whether this pair's Forgejo side 156// already receives pushes directly from another pair's Forgejo via 157// Authorized Integrations (set by SetAuthorizedIntegrationSource at 158// startup) — used by the dashboard to explain why this side shows no 159// graft-logged events even though it holds the mirrored content. 160func (rs *RepoSyncer) HasAuthorizedIntegrationSource() bool { 161 return rs.git != nil && rs.git.AuthorizedIntegrationSource != "" 162} 163 164// Series is the dashboard row this pair's activity groups under: its 165// configured series, or its own name if unset. 166func (rs *RepoSyncer) Series() string { 167 if rs.pair.Series != "" { 168 return rs.pair.Series 169 } 170 return rs.pair.Name 171} 172 173// CommentOnItem posts a reply (from the ActivityPub comment bridge, see 174// internal/activitypub) onto the underlying Forgejo issue/PR and Radicle 175// issue/patch this pair mirrors. For a patch, radicleID is used as the 176// revision to comment on — correct for the common single-revision case, 177// since a patch's own id equals its first revision's id; a patch that has 178// since been updated with further revisions may have the comment attach 179// to an older revision instead of the latest one, a known scoped 180// limitation rather than a bug to chase down for v2. 181func (rs *RepoSyncer) CommentOnItem(kind string, forgejoID int64, radicleID, body string) error { 182 var errs []error 183 if forgejoID != 0 { 184 if err := rs.forgejo.CreateIssueComment(forgejoID, body); err != nil { 185 errs = append(errs, fmt.Errorf("forgejo: %w", err)) 186 } 187 } 188 if radicleID != "" { 189 var err error 190 switch kind { 191 case "issue": 192 err = rs.radicle.CommentIssue(radicleID, body) 193 case "patch": 194 err = rs.radicle.CommentPatch(radicleID, body) 195 default: 196 err = fmt.Errorf("cannot comment on kind %q", kind) 197 } 198 if err != nil { 199 errs = append(errs, fmt.Errorf("radicle: %w", err)) 200 } 201 } 202 return errors.Join(errs...) 203} 204 205// RadicleDID returns the local Radicle node identity this pair pushes as, 206// resolved the same way GitSyncer resolves it for remote setup (`rad self 207// --nid`). Empty if this pair has no git sync configured, so nothing to 208// report. 209func (rs *RepoSyncer) RadicleDID() (string, error) { 210 if rs.git == nil { 211 return "", nil 212 } 213 nid, err := rs.git.localNodeID() 214 if err != nil { 215 return "", err 216 } 217 if nid == "" { 218 return "", nil 219 } 220 return "did:key:" + nid, nil 221} 222 223// Run executes every enabled scope once, logging and continuing past 224// per-scope errors so one broken scope doesn't block the others. It 225// returns each scope's error (nil if disabled or successful) for the 226// caller to report via internal/status. 227func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) { 228 log = log.With("pair", rs.pair.Name) 229 230 if rs.git != nil { 231 if gitErr = rs.git.Sync(); gitErr != nil { 232 log.Error("git sync failed", "err", gitErr) 233 } 234 } 235 if rs.issues != nil { 236 if issuesErr = rs.issues.Sync(); issuesErr != nil { 237 log.Error("issue sync failed", "err", issuesErr) 238 } 239 } 240 if rs.patches != nil { 241 if patchErr = rs.patches.Sync(); patchErr != nil { 242 log.Error("patch sync failed", "err", patchErr) 243 } 244 } 245 return gitErr, issuesErr, patchErr 246} 247 248// authenticatedCloneURL embeds a token into an HTTPS clone URL the way 249// Forgejo expects (token as the basic-auth username). 250func authenticatedCloneURL(cloneURL, token string) string { 251 u, err := url.Parse(cloneURL) 252 if err != nil { 253 return cloneURL 254 } 255 u.User = url.User(token) 256 return u.String() 257} 258 259// WorkDirFor returns the per-pair git mirror directory under stateDir. 260func WorkDirFor(stateDir, pairName string) string { 261 return filepath.Join(stateDir, "repos", pairName) 262}