// Package status tracks the outcome of each repo pair's most recent sync // pass, keeps a small dashboard over the activity log, and serves both over // HTTP — so an operator can tell the daemon is alive and see what it's // actually done without reading logs. package status import ( "encoding/json" "html/template" "net/http" "net/url" "sort" "strings" "sync" "time" "graft/internal/state" ) // PairResult is the outcome of one repo pair's most recent pass. An empty // error string means that scope is either disabled or succeeded. type PairResult struct { Name string `json:"name"` Series string `json:"series"` LastRun time.Time `json:"last_run"` GitError string `json:"git_error,omitempty"` IssuesError string `json:"issues_error,omitempty"` PatchError string `json:"patch_error,omitempty"` } func (r PairResult) ok() bool { return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" } // errText joins whichever scopes actually failed on this pair's last pass // into one tooltip-sized message. Empty if the pass was clean. func (r PairResult) errText() string { var parts []string if r.GitError != "" { parts = append(parts, "git: "+r.GitError) } if r.IssuesError != "" { parts = append(parts, "issues: "+r.IssuesError) } if r.PatchError != "" { parts = append(parts, "patches: "+r.PatchError) } return strings.Join(parts, " | ") } // ServerRef is one repo pair's declared mirror target — a specific Forgejo // instance or a specific Radicle node — as configured, regardless of // whether it has ever actually produced an event yet. type ServerRef struct { Label string // host, e.g. "f1.cyberwild.org" URL string // repo root on that host Radicle bool // true if this side is a Radicle node AuthorizedIntegration bool // true if this Forgejo side receives pushes directly from another pair's Forgejo, bypassing graft PairName string // the config.RepoPair.Name this side belongs to — links a sub-row back to its PairResult for error surfacing } // Tracker holds the latest result per repo pair, safe for concurrent use, // plus a handle on the state store for the activity dashboard. type Tracker struct { startedAt time.Time store *state.Store sourceURL string mu sync.Mutex pairs map[string]PairResult topology map[string][]ServerRef publicHost string blueskyConfigured map[string]bool } func NewTracker(store *state.Store, sourceURL string) *Tracker { return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} } // SetTopology declares every mirror target configured for each series, so // the dashboard can show a side that's never produced an event yet (e.g. a // Radicle node nothing has been pushed to) instead of only sides the // activity log happens to mention. func (t *Tracker) SetTopology(topology map[string][]ServerRef) { t.mu.Lock() defer t.mu.Unlock() t.topology = topology } // SetPublicHost declares the host graft is reachable at (e.g. // graft.cyberwild.org), enabling the "Social bridges" link and the // /social page. Call with "" (the default) to leave ActivityPub disabled. func (t *Tracker) SetPublicHost(host string) { t.mu.Lock() defer t.mu.Unlock() t.publicHost = host } // SetBlueskyConfigured declares which series have a Bluesky account // configured, for the /social page's status badge. func (t *Tracker) SetBlueskyConfigured(configured map[string]bool) { t.mu.Lock() defer t.mu.Unlock() t.blueskyConfigured = configured } // Record stores the outcome of a pass for one pair. Pass nil for a scope // that succeeded or wasn't enabled. series is the dashboard row this pair's // status badge groups under (see config.RepoPair.Series). func (t *Tracker) Record(name, series string, gitErr, issuesErr, patchErr error) { r := PairResult{Name: name, Series: series, LastRun: time.Now()} if gitErr != nil { r.GitError = gitErr.Error() } if issuesErr != nil { r.IssuesError = issuesErr.Error() } if patchErr != nil { r.PatchError = patchErr.Error() } t.mu.Lock() defer t.mu.Unlock() t.pairs[name] = r } func (t *Tracker) snapshot() []PairResult { t.mu.Lock() defer t.mu.Unlock() out := make([]PairResult, 0, len(t.pairs)) for _, r := range t.pairs { out = append(out, r) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } type statusResponse struct { StartedAt time.Time `json:"started_at"` Pairs []PairResult `json:"pairs"` } // Handler serves: // // GET /healthz - 200 if every pair's last pass was clean, 503 otherwise // GET /api/status - the JSON snapshot Record() has been fed // GET / - the activity dashboard (calendar + recent commits) func (t *Tracker) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { for _, p := range t.snapshot() { if !p.ok() { w.WriteHeader(http.StatusServiceUnavailable) return } } w.WriteHeader(http.StatusOK) }) mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) }) mux.HandleFunc("/", t.serveDashboard) mux.HandleFunc("/social", t.serveSocial) return mux } const dashboardDays = 3 func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { since := time.Now().UTC().AddDate(0, 0, -(dashboardDays - 1)).Truncate(24 * time.Hour) entries, err := t.store.ActivitySince(since) if err != nil { http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) return } results := t.snapshot() pairs := groupPairStatus(results) health := make(map[string]bool, len(pairs)) for _, p := range pairs { health[p.Name] = p.OK } pairResults := make(map[string]PairResult, len(results)) for _, r := range results { pairResults[r.Name] = r } t.mu.Lock() topology := t.topology publicHost := t.publicHost t.mu.Unlock() data := dashboardData{ Series: buildSeriesRows(entries, health, topology, pairResults), Activity: recentActivity(entries), SourceURL: t.sourceURL, SocialEnabled: publicHost != "", } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := dashboardTmpl.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // socialData is what the /social page renders: one row per series // describing its ActivityPub and AT Proto/Bluesky bridges. Built // incrementally across the v2 milestones — see the plan for what each // stage adds (follower counts, delivery timestamps, bridged-comment // counts, Bluesky status). type socialData struct { PublicHost string Series []socialRow SourceURL string } type socialRow struct { Name string Handle string // "series@host" WebfingerURL string ActorURL string OutboxURL string FollowerCount int BlueskyConfigured bool } func (t *Tracker) serveSocial(w http.ResponseWriter, r *http.Request) { t.mu.Lock() topology := t.topology publicHost := t.publicHost blueskyConfigured := t.blueskyConfigured t.mu.Unlock() if publicHost == "" { http.NotFound(w, r) return } names := make([]string, 0, len(topology)) for name := range topology { names = append(names, name) } sort.Strings(names) rows := make([]socialRow, 0, len(names)) for _, name := range names { actorURL := "https://" + publicHost + "/actors/" + name followerCount, err := t.store.FollowerCount(name) if err != nil { followerCount = 0 } rows = append(rows, socialRow{ Name: name, Handle: name + "@" + publicHost, WebfingerURL: "https://" + publicHost + "/.well-known/webfinger?resource=acct:" + name + "@" + publicHost, ActorURL: actorURL, OutboxURL: actorURL + "/outbox", FollowerCount: followerCount, BlueskyConfigured: blueskyConfigured[name], }) } data := socialData{PublicHost: publicHost, Series: rows, SourceURL: t.sourceURL} w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := socialTmpl.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } type dashboardData struct { Series []seriesRow Activity []commitLine SourceURL string SocialEnabled bool } // pairStatus is one badge in the dashboard's top status card: one per // series (repo), OK only if every pair mirroring that repo's last pass was // clean — grouped so a repo with 3 mirrored sides (e.g. two Forgejos plus // an external instance) shows one badge, not three. type pairStatus struct { Name string OK bool } func groupPairStatus(results []PairResult) []pairStatus { order := []string{} ok := map[string]bool{} for _, r := range results { name := r.Series if name == "" { name = r.Name } if _, seen := ok[name]; !seen { order = append(order, name) ok[name] = true } if !r.ok() { ok[name] = false } } sort.Strings(order) out := make([]pairStatus, 0, len(order)) for _, name := range order { out = append(out, pairStatus{Name: name, OK: ok[name]}) } return out } // seriesRow is one full-width strip of the activity heatmap: everything // mirrored for one underlying repository (grouped across its Forgejo and // Radicle sides via config.RepoPair.Series), one cell per actual event — // not one per day — so nothing is ever aggregated behind a single tooltip. type seriesRow struct { Name string OK bool SubRows []subRow } // subRow is one mirrored side of a series — one real repo (a specific // Forgejo instance, or a specific Radicle node) — shown once the series is // expanded, with only that side's own events and its own link. A series // with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never // one link picked arbitrarily to represent all of them. type subRow struct { Label string URL string Events []event // Error is set when this side's most recent sync pass actually // failed — surfaced as a visible badge instead of a silently // shorter row. A row with an Error never gets the synthetic // alignment fill below: what you see is only what's confirmed. Error string } // event is one heatmap cell, one tooltip, one link — either a real // activity_log row, or (when State is set) a synthetic cell standing in for // content this side plainly holds without graft ever having logged a push // to it directly: the Forgejo side changes originate on ("source"), or a // side that received the content some other way graft doesn't log itself — // Radicle's own peer-to-peer gossip, or a Forgejo-to-Forgejo Authorized // Integration ("replicated"). Synthetic cells reuse the real event they // stand in for (same kind, date, summary) so they read exactly like a // normal commit/issue/patch cell, just in a different color, with the // tooltip naming where it actually happened. type event struct { Kind string // "git", "issue", "patch", "comment" — always drives the cell's color Label string // "Commit", "Issue", "Patch", "Comment" Text string URL string Server string // which mirrored side this happened on, e.g. "f1", "alice" When string // human-readable timestamp, for the tooltip // State and OriginLabel exist only for a synthetic cell — one // standing in for content a side plainly holds without graft having // logged a push there directly (see buildSeriesRows). Never drives // the cell's color (Kind always does); surfaces only as a tooltip // note explaining why this side has no event of its own. State string // "" for a real event; "source" or "replicated" for a synthetic one OriginLabel string // for a synthetic cell, the sub-row label the real event was actually logged on // Origin is set on a real "comment" kind event: where it actually // happened — "Forgejo", "Radicle", "Fediverse (ActivityPub)", or // "Bluesky (AT Proto)". Tooltip note only, same as State/OriginLabel. Origin string } // originLabel turns a stored activity_log.origin value into display // text for the tooltip. func originLabel(origin string) string { switch origin { case "forgejo": return "Forgejo" case "radicle": return "Radicle" case "activitypub": return "Fediverse (ActivityPub)" case "atproto": return "Bluesky (AT Proto)" default: return "" } } type commitLine struct { When string Pair string PairURL string Server string Kind string // "Commit", "Issue", "Patch" Message string URL string } // serverLabel names which side of a series a repo_pair belongs to, e.g. // "federation-x-f1" under series "federation-x" becomes "f1". Several pairs // share one series row/badge, so without this an operator can't tell which // mirrored server (f1, f2, an external instance...) a given event actually // happened on. Empty if the pair name doesn't start with its series name. func serverLabel(repoPair, series string) string { if series == "" { return "" } suffix := strings.TrimPrefix(repoPair, series+"-") if suffix == repoPair { return "" } return suffix } // hostLabel shortens a URL's host to its canonical name for compact // display: the TLD is dropped ("f1.cyberwild.org" -> "f1.cyberwild", // "artefacts.bimr.net" -> "artefacts.bimr"), so every server — ours or an // external instance's — reads the same way, never a per-pair alias. func hostLabel(rawURL string) string { u, err := url.Parse(rawURL) if err != nil || u.Host == "" { return "" } host := u.Host // A Radicle Explorer URL's host is always the shared frontend // (e.g. radicle.cyberwild.org) — the actual node lives in the path, // /nodes///... — so different Radicle nodes get // distinct labels instead of collapsing into one shared one. if segs := strings.Split(strings.TrimPrefix(u.Path, "/"), "/"); len(segs) >= 2 && segs[0] == "nodes" { host = segs[1] } return host } // buildSeriesRows groups entries by their Series (one row per monitored // repo, spanning both its Forgejo and Radicle sides) — Atlassian // Statuspage style: one full-width horizontal strip per series. Each entry // becomes its own cell, oldest first, so a busy day never hides events // behind one shared tooltip. func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool, topology map[string][]ServerRef, pairResults map[string]PairResult) []seriesRow { type subAcc struct { url string events []state.ActivityEntry radicle bool ai bool // err is this side's live sync-error message (empty if its last // pass was clean). Set from pairResults via the topology's // PairName, so it reflects reality even before this side has // ever logged an event of its own. err string // byKey indexes events by dedupKey — built once per side right // before rendering, see buildSeriesRows. byKey map[string]state.ActivityEntry } type seriesAcc struct { subOrder []string subs map[string]*subAcc } seriesAccs := map[string]*seriesAcc{} var seriesOrder []string ensureSeries := func(name string) *seriesAcc { sa, ok := seriesAccs[name] if !ok { sa = &seriesAcc{subs: map[string]*subAcc{}} seriesAccs[name] = sa seriesOrder = append(seriesOrder, name) } return sa } ensureSub := func(sa *seriesAcc, label string) *subAcc { sub, ok := sa.subs[label] if !ok { sub = &subAcc{} sa.subs[label] = sub sa.subOrder = append(sa.subOrder, label) } return sub } // Seed every configured mirror target first, so a side that has never // produced an event (e.g. a Radicle node nothing has been pushed to // yet) still gets its own row instead of being invisible. for name, refs := range topology { sa := ensureSeries(name) for _, ref := range refs { sub := ensureSub(sa, ref.Label) if sub.url == "" { sub.url = ref.URL } sub.radicle = ref.Radicle sub.ai = ref.AuthorizedIntegration if pr, ok := pairResults[ref.PairName]; ok { sub.err = pr.errText() } } } for _, e := range entries { name := e.Series if name == "" { name = e.RepoPair } sa := ensureSeries(name) server := hostLabel(e.URL) if server == "" { server = serverLabel(e.RepoPair, e.Series) } if server == "" { server = e.RepoPair } sub := ensureSub(sa, server) if sub.url == "" { sub.url = repoRootURL(e.URL) } sub.events = append(sub.events, e) } sort.Strings(seriesOrder) rows := make([]seriesRow, 0, len(seriesOrder)) for _, name := range seriesOrder { sa := seriesAccs[name] subOrder := append([]string(nil), sa.subOrder...) sort.Strings(subOrder) // distinctEvents dedupes every event logged anywhere in the series // down to one representative per real underlying commit/issue/patch // — keyed by commit SHA for git (content-addressed, stable across // every mirror), or by (kind, summary) otherwise — keeping // whichever occurrence was logged earliest, since that's the // closest graft gets to "when this actually entered the series". // A series with at least one logged event anywhere means every // currently-silent side already holds this content too — either // as the origin, or received via a path graft doesn't log itself // (Radicle gossip, an Authorized Integration) — so those sides // get synthetic cells built from this map instead of the usual // per-event ones. Only a series with zero events anywhere stays // genuinely empty. type rep struct { entry state.ActivityEntry label string } reps := map[string]rep{} for _, label := range subOrder { for _, e := range sa.subs[label].events { key := dedupKey(e.Kind, e.URL, e.Summary) cur, ok := reps[key] if !ok || e.OccurredAt.Before(cur.entry.OccurredAt) { reps[key] = rep{entry: e, label: label} } } } repKeys := make([]string, 0, len(reps)) for k := range reps { repKeys = append(repKeys, k) } sort.Slice(repKeys, func(i, j int) bool { return reps[repKeys[i]].entry.OccurredAt.Before(reps[repKeys[j]].entry.OccurredAt) }) // byKey lets the render loop below ask "did this specific side log // this specific event itself?" in O(1), so a real cell is used // wherever one exists and a synthetic one only fills the gaps — // never the other way around. for _, sub := range sa.subs { sub.byKey = make(map[string]state.ActivityEntry, len(sub.events)) for _, e := range sub.events { sub.byKey[dedupKey(e.Kind, e.URL, e.Summary)] = e } } subRows := make([]subRow, 0, len(subOrder)) for _, label := range subOrder { sub := sa.subs[label] if sub.err != "" { // A side with a live sync error doesn't get the alignment // fill below — showing it as "caught up" would hide the // exact problem this dashboard exists to surface. What's // rendered is only what this side has actually confirmed // itself, so a real gap stays visible, backed by the // error badge the template renders from subRow.Error. es := make([]state.ActivityEntry, len(sub.events)) copy(es, sub.events) sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es), Error: sub.err}) continue } if len(reps) == 0 { subRows = append(subRows, subRow{Label: label, URL: sub.url}) continue } // A healthy side (no live error) always renders the full, // series-wide set of distinct events in the same order, so // two sides genuinely holding the same content always look // identical — real cells wherever this side logged the event // itself, synthetic ones filling in the rest (received via // Radicle gossip, an Authorized Integration, or simply not // the side that happened to get logged for that push). cellState := "source" if sub.ai || sub.radicle { cellState = "replicated" } cells := make([]event, 0, len(repKeys)) for _, k := range repKeys { if real, ok := sub.byKey[k]; ok { cells = append(cells, toEvent(real)) continue } r := reps[k] cells = append(cells, event{ Kind: r.entry.Kind, Label: kindLabel(r.entry.Kind), Text: r.entry.Summary, URL: reconstructLink(sub.url, r.entry.Kind, r.entry.URL), Server: label, When: r.entry.OccurredAt.Format("Mon, Jan 2, 15:04"), State: cellState, OriginLabel: r.label, }) } subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: cells}) } ok, known := health[name] if !known { ok = true } rows = append(rows, seriesRow{Name: name, OK: ok, SubRows: subRows}) } return rows } func toEvent(e state.ActivityEntry) event { server := hostLabel(e.URL) if server == "" { server = serverLabel(e.RepoPair, e.Series) } return event{ Kind: e.Kind, Label: kindLabel(e.Kind), Text: e.Summary, URL: e.URL, Server: server, When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), Origin: originLabel(e.Origin), } } func toEvents(es []state.ActivityEntry) []event { out := make([]event, 0, len(es)) for _, e := range es { out = append(out, toEvent(e)) } return out } func kindLabel(kind string) string { switch kind { case "git": return "Commit" case "issue": return "Issue" case "patch": return "Patch" case "comment": return "Comment" default: return kind } } // recentActivityLimit caps how many rows the dashboard's recent-activity // list renders. Without it, a busy sync window (the 3-day ActivitySince // lookback in dashboardData) can hand recentActivity thousands of entries — // once observed at 16,621 rows, a 10.6MB page taking 3.1s to load — even // though the list is only ever meant to show a short "what just happened" // glance, not a full history. Capping the render, not the query: entries // are already newest-first, so this keeps the most recent ones. const recentActivityLimit = 10 // recentActivity lists the most recent mirrored events — commits, issues, // and patches/pull requests alike, newest first, capped at // recentActivityLimit. graft doesn't mirror stars or other social // metadata: its sync scope is git content, issues, and patches/PRs only // (see config.SyncScope), so that's what shows up here. func recentActivity(entries []state.ActivityEntry) []commitLine { if len(entries) > recentActivityLimit { entries = entries[:recentActivityLimit] } out := make([]commitLine, 0, len(entries)) for _, e := range entries { name := e.Series if name == "" { name = e.RepoPair } server := hostLabel(e.URL) if server == "" { server = serverLabel(e.RepoPair, e.Series) } // The (server) parenthetical should point at that specific // server's own repo, not the series' shared Radicle explorer // link (which is fixed to whichever pair logged first). pairURL := repoRootURL(e.URL) if pairURL == "" { pairURL = e.SeriesURL } out = append(out, commitLine{ When: e.OccurredAt.Format("Jan 2, 15:04"), Pair: name, PairURL: pairURL, Server: server, Kind: kindLabel(e.Kind), Message: e.Summary, URL: e.URL, }) } return out } // repoRootURL trims an event's specific-item URL (a commit, issue, or // patch/PR page) back down to its repository root, e.g. // ".../owner/repo/commit/" -> ".../owner/repo". Empty if rawURL is // empty or doesn't contain a recognized item path. func repoRootURL(rawURL string) string { // Forgejo uses singular "/commit/"; Radicle Explorer uses plural // "/commits/" — both must be recognized, or every Radicle-hosted // commit link falls back to a non-clickable label. for _, marker := range []string{"/commit/", "/commits/", "/issues/", "/pulls/", "/patches/"} { if idx := strings.Index(rawURL, marker); idx >= 0 { return rawURL[:idx] } } return "" } // commitSHA pulls the trailing commit hash out of a git-item URL, e.g. // ".../owner/repo/commit/" or ".../repo/commits/" -> "". // Empty if rawURL isn't a recognized commit link. func commitSHA(rawURL string) string { for _, marker := range []string{"/commit/", "/commits/"} { idx := strings.Index(rawURL, marker) if idx < 0 { continue } sha := rawURL[idx+len(marker):] if i := strings.IndexAny(sha, "/?#"); i >= 0 { sha = sha[:i] } return sha } return "" } // dedupKey identifies "the same underlying event" across every mirror it // got logged on. A commit is content-addressed — its SHA is identical on // every side it's mirrored to — so that's the stable key for git events. // Issues and patches get local, per-instance IDs that aren't portable // across hosts, so their summary text is the best available stand-in. func dedupKey(kind, url, summary string) string { if sha := commitSHA(url); sha != "" { return kind + ":" + sha } return kind + ":" + summary } // reconstructLink builds a same-host link for a synthetic cell: a commit's // SHA is identical everywhere it's mirrored, so subURL (this side's own // repo root) plus the right per-host item path reaches the same commit on // this side specifically, rather than sending the reader to the side it // was actually logged on. Falls back to the original event's own URL for // anything that isn't a reconstructible git link — an issue or patch ID // isn't portable across hosts, so linking to where it's actually visible // beats guessing. func reconstructLink(subURL, kind, originURL string) string { sha := commitSHA(originURL) if kind != "git" || sha == "" || subURL == "" { return originURL } if strings.Contains(subURL, "/nodes/") { return subURL + "/commits/" + sha } return subURL + "/commit/" + sha } var dashboardTmpl = template.Must(template.New("dashboard").Parse(` graft

graft

Forgejos & Radicles syncs
{{if .SocialEnabled}}Social IDs →{{end}}
Commit Issue Patch Comment
{{range .Series}}
{{.Name}} {{len .SubRows}} mirror{{if ne (len .SubRows) 1}}s{{end}}
{{range .SubRows}}
{{if .Error}}
last pass failed
{{.Error}}
{{end}} {{if .URL}} {{.Label}} {{else}} {{.Label}} {{end}}
{{range .Events}}
{{.When}}
{{if .URL}} {{.Label}}{{.Text}} {{else}} {{.Label}}{{.Text}} {{end}} {{if eq .State "source"}}
origin of this content — mirrored out from here, via {{.OriginLabel}}
{{else if eq .State "replicated"}}
replicated here without a graft-logged push — originally pushed via {{.OriginLabel}}, {{.When}}
{{end}} {{if .Origin}}
via {{.Origin}}
{{end}}
{{else}} nothing synced {{end}}
{{end}}
{{else}} nothing synced yet {{end}}

Recent activity

last 10 events
{{range .Activity}}
{{.When}} {{.Kind}} {{if .PairURL}} {{.Pair}}{{if .Server}} ({{.Server}}){{end}} {{else}} {{.Pair}}{{if .Server}} ({{.Server}}){{end}} {{end}} {{if .URL}} {{.Message}} {{else}} {{.Message}} {{end}}
{{else}}

nothing mirrored yet

{{end}}
`)) var socialTmpl = template.Must(template.New("social").Parse(` graft — social IDs

graft — social IDs

ActivityPub + AT Proto
← Dashboard
{{range .Series}}
@{{.Handle}}

ActivityPub · {{.FollowerCount}} follower{{if ne .FollowerCount 1}}s{{end}}

AT Proto / Bluesky

{{if .BlueskyConfigured}}posts on new activity{{else}}not configured{{end}}
{{else}} no series configured {{end}}
`))