package sync import ( "fmt" "log/slog" "net/url" "path/filepath" "strings" "graft/internal/config" "graft/internal/forgejo" "graft/internal/radicle" "graft/internal/state" ) // RepoSyncer runs one repo pair's enabled sync scopes for one pass. type RepoSyncer struct { pair config.RepoPair forgejo *forgejo.Client radicle *radicle.Client git *GitSyncer issues *IssueSyncer patches *PatchSyncer } // New builds a RepoSyncer for one configured pair. workDir is where this // pair's local git mirror is kept, e.g. /. func New(pair config.RepoPair, st *state.Store, workDir string) (*RepoSyncer, error) { token, err := config.ReadToken(pair.Forgejo.TokenFile) if err != nil { return nil, err } fc := forgejo.New(pair.Forgejo.BaseURL, pair.Forgejo.Owner, pair.Forgejo.Repo, token) repo, err := fc.GetRepository() if err != nil { return nil, fmt.Errorf("resolve forgejo repository: %w", err) } rc := radicle.New(pair.Radicle.HTTPBaseURL, pair.Radicle.RID, pair.Radicle.RadHome, "rad") rs := &RepoSyncer{pair: pair, forgejo: fc, radicle: rc} forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo radicleWebURL := radicleExplorerLink(pair.Radicle) if pair.Sync.Git || pair.Sync.Patches { rs.git = &GitSyncer{ RepoPair: pair.Name, WorkDir: workDir, ForgejoURL: authenticatedCloneURL(repo.CloneURL, token), ForgejoWebURL: forgejoWebURL, ForgejoBranch: repo.DefaultBranch, RID: pair.Radicle.RID, RadHome: pair.Radicle.RadHome, State: st, } } if pair.Sync.Issues { rs.issues = &IssueSyncer{ RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st, ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL, } } if pair.Sync.Patches { rs.patches = &PatchSyncer{ RepoPair: pair.Name, WorkDir: workDir, RadHome: pair.Radicle.RadHome, DefaultBranch: repo.DefaultBranch, Forgejo: fc, Radicle: rc, State: st, ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL, } } return rs, nil } // radicleExplorerLink builds the base URL for "view this on Radicle" links: // /nodes//. Empty if no explorer_url is // configured, so templates can skip the link entirely. func radicleExplorerLink(rt config.RadicleTarget) string { if rt.ExplorerURL == "" { return "" } host := rt.HTTPBaseURL if u, err := url.Parse(rt.HTTPBaseURL); err == nil && u.Host != "" { host = u.Host } return strings.TrimRight(rt.ExplorerURL, "/") + "/nodes/" + host + "/" + rt.RID } // Name is this pair's name, as given in the config. func (rs *RepoSyncer) Name() string { return rs.pair.Name } // Run executes every enabled scope once, logging and continuing past // per-scope errors so one broken scope doesn't block the others. It // returns each scope's error (nil if disabled or successful) for the // caller to report via internal/status. func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) { log = log.With("pair", rs.pair.Name) if rs.git != nil { if gitErr = rs.git.Sync(); gitErr != nil { log.Error("git sync failed", "err", gitErr) } } if rs.issues != nil { if issuesErr = rs.issues.Sync(); issuesErr != nil { log.Error("issue sync failed", "err", issuesErr) } } if rs.patches != nil { if patchErr = rs.patches.Sync(); patchErr != nil { log.Error("patch sync failed", "err", patchErr) } } return gitErr, issuesErr, patchErr } // authenticatedCloneURL embeds a token into an HTTPS clone URL the way // Forgejo expects (token as the basic-auth username). func authenticatedCloneURL(cloneURL, token string) string { u, err := url.Parse(cloneURL) if err != nil { return cloneURL } u.User = url.User(token) return u.String() } // WorkDirFor returns the per-pair git mirror directory under stateDir. func WorkDirFor(stateDir, pairName string) string { return filepath.Join(stateDir, "repos", pairName) }