Federated via graft
0

Configure Feed

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

Fix issue-id parsing bug, log activity, add status dashboard

rad issue open --quiet never prints the created issue's id (only a sync
summary), which silently corrupted the stored radicle_id and caused every
issue to be re-mirrored on each pass. Fixed by dropping --quiet and parsing
the id out of the normal output box instead.

Also adds an append-only activity_log table and a small HTTP dashboard
(GitHub-style activity calendar + recent commits list) served alongside
the existing /healthz and /api/status endpoints.

+538 -39
+14 -1
cmd/sync/main.go
··· 6 6 import ( 7 7 "flag" 8 8 "log/slog" 9 + "net/http" 9 10 "os" 10 11 "time" 11 12 12 13 "graft/internal/config" 13 14 "graft/internal/state" 15 + "graft/internal/status" 14 16 "graft/internal/sync" 15 17 ) 16 18 17 19 func main() { 18 20 configPath := flag.String("config", "/etc/graft/config.yaml", "path to config.yaml") 19 21 once := flag.Bool("once", false, "run a single pass and exit, instead of looping") 22 + listen := flag.String("listen", ":8090", "address to serve /healthz and / (status) on; empty disables it") 20 23 flag.Parse() 21 24 22 25 log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) ··· 49 52 syncers = append(syncers, rs) 50 53 } 51 54 55 + tracker := status.NewTracker(st) 52 56 runAll := func() { 53 57 for _, rs := range syncers { 54 - rs.Run(log) 58 + gitErr, issuesErr, patchErr := rs.Run(log) 59 + tracker.Record(rs.Name(), gitErr, issuesErr, patchErr) 55 60 } 61 + } 62 + 63 + if *listen != "" { 64 + go func() { 65 + if err := http.ListenAndServe(*listen, tracker.Handler()); err != nil { 66 + log.Error("status server stopped", "err", err) 67 + } 68 + }() 56 69 } 57 70 58 71 runAll()
+12 -17
internal/radicle/client.go
··· 11 11 "net/http" 12 12 "os" 13 13 "os/exec" 14 + "regexp" 14 15 "time" 15 16 ) 16 17 ··· 100 101 return issues, nil 101 102 } 102 103 104 + // issueIDRe matches the "Issue <id>" line in rad's non-quiet issue-open 105 + // output box. --quiet was tried first and rejected: it suppresses the id 106 + // entirely (verified against a live node), printing only the sync summary, 107 + // which silently corrupted the stored mapping and caused every issue to be 108 + // re-mirrored on each pass. 109 + var issueIDRe = regexp.MustCompile(`Issue\s+([0-9a-f]{40})`) 110 + 103 111 // OpenIssue creates a new issue via the CLI (no write API exists on httpd). 104 112 func (c *Client) OpenIssue(title, description string) (string, error) { 105 - out, err := c.rad("issue", "open", "-t", title, "-d", description, "--quiet") 113 + out, err := c.rad("issue", "open", "-t", title, "-d", description) 106 114 if err != nil { 107 115 return "", err 108 116 } 109 - // --quiet still prints the new issue id on its own line. 110 - id := trimLastLine(out) 111 - if id == "" { 117 + m := issueIDRe.FindStringSubmatch(out) 118 + if m == nil { 112 119 return "", fmt.Errorf("rad issue open: could not parse issue id from output: %q", out) 113 120 } 114 - return id, nil 121 + return m[1], nil 115 122 } 116 123 117 124 // PatchState mirrors radicle-httpd's patch state object. ··· 140 147 } 141 148 return patches, nil 142 149 } 143 - 144 - func trimLastLine(s string) string { 145 - start := len(s) 146 - for start > 0 && s[start-1] == '\n' { 147 - start-- 148 - } 149 - end := start 150 - for start > 0 && s[start-1] != '\n' { 151 - start-- 152 - } 153 - return s[start:end] 154 - }
+71
internal/state/store.go
··· 6 6 import ( 7 7 "database/sql" 8 8 "fmt" 9 + "time" 9 10 10 11 _ "modernc.org/sqlite" 11 12 ) ··· 54 55 ON item_mapping (repo_pair, kind, forgejo_id); 55 56 CREATE INDEX IF NOT EXISTS idx_item_mapping_radicle 56 57 ON item_mapping (repo_pair, kind, radicle_id); 58 + 59 + -- One row per thing actually mirrored: a commit, an issue, a patch. This is 60 + -- what the status page's activity calendar and commit list read from — it's 61 + -- an append-only log, never updated, so it can't itself be a source of the 62 + -- kind of state-loss bug that hit item_mapping. 63 + CREATE TABLE IF NOT EXISTS activity_log ( 64 + id INTEGER PRIMARY KEY AUTOINCREMENT, 65 + repo_pair TEXT NOT NULL, 66 + kind TEXT NOT NULL, -- 'git', 'issue', 'patch' 67 + direction TEXT NOT NULL, -- 'forgejo_to_radicle' or 'radicle_to_forgejo' 68 + summary TEXT NOT NULL, 69 + occurred_at TEXT NOT NULL 70 + ); 71 + 72 + CREATE INDEX IF NOT EXISTS idx_activity_log_occurred_at 73 + ON activity_log (occurred_at); 57 74 `) 58 75 return err 59 76 } ··· 126 143 `, repoPair, m.Kind, m.ForgejoID, m.RadicleID, m.ContentHash) 127 144 return err 128 145 } 146 + 147 + // Direction names for LogActivity, kept as constants so callers can't typo 148 + // a value the status page's rendering silently fails to recognize. 149 + const ( 150 + ForgejoToRadicle = "forgejo_to_radicle" 151 + RadicleToForgejo = "radicle_to_forgejo" 152 + ) 153 + 154 + // LogActivity records one thing that was actually mirrored, for the status 155 + // page. Call it once per commit/issue/patch, not once per sync pass. 156 + func (s *Store) LogActivity(repoPair, kind, direction, summary string) error { 157 + _, err := s.db.Exec(` 158 + INSERT INTO activity_log (repo_pair, kind, direction, summary, occurred_at) 159 + VALUES (?, ?, ?, ?, ?) 160 + `, repoPair, kind, direction, summary, time.Now().UTC().Format(time.RFC3339)) 161 + return err 162 + } 163 + 164 + // ActivityEntry is one row of activity_log. 165 + type ActivityEntry struct { 166 + RepoPair string 167 + Kind string 168 + Direction string 169 + Summary string 170 + OccurredAt time.Time 171 + } 172 + 173 + // ActivitySince returns every activity entry at or after since, newest first. 174 + func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) { 175 + rows, err := s.db.Query(` 176 + SELECT repo_pair, kind, direction, summary, occurred_at FROM activity_log 177 + WHERE occurred_at >= ? 178 + ORDER BY occurred_at DESC 179 + `, since.UTC().Format(time.RFC3339)) 180 + if err != nil { 181 + return nil, err 182 + } 183 + defer rows.Close() 184 + 185 + var out []ActivityEntry 186 + for rows.Next() { 187 + var e ActivityEntry 188 + var occurredAt string 189 + if err := rows.Scan(&e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &occurredAt); err != nil { 190 + return nil, err 191 + } 192 + e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt) 193 + if err != nil { 194 + return nil, fmt.Errorf("parse occurred_at %q: %w", occurredAt, err) 195 + } 196 + out = append(out, e) 197 + } 198 + return out, rows.Err() 199 + }
+372
internal/status/status.go
··· 1 + // Package status tracks the outcome of each repo pair's most recent sync 2 + // pass, keeps a small dashboard over the activity log, and serves both over 3 + // HTTP — so an operator can tell the daemon is alive and see what it's 4 + // actually done without reading logs. 5 + package status 6 + 7 + import ( 8 + "encoding/json" 9 + "fmt" 10 + "html/template" 11 + "net/http" 12 + "sort" 13 + "strings" 14 + "sync" 15 + "time" 16 + 17 + "graft/internal/state" 18 + ) 19 + 20 + // PairResult is the outcome of one repo pair's most recent pass. An empty 21 + // error string means that scope is either disabled or succeeded. 22 + type PairResult struct { 23 + Name string `json:"name"` 24 + LastRun time.Time `json:"last_run"` 25 + GitError string `json:"git_error,omitempty"` 26 + IssuesError string `json:"issues_error,omitempty"` 27 + PatchError string `json:"patch_error,omitempty"` 28 + } 29 + 30 + func (r PairResult) ok() bool { 31 + return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" 32 + } 33 + 34 + // Tracker holds the latest result per repo pair, safe for concurrent use, 35 + // plus a handle on the state store for the activity dashboard. 36 + type Tracker struct { 37 + startedAt time.Time 38 + store *state.Store 39 + 40 + mu sync.Mutex 41 + pairs map[string]PairResult 42 + } 43 + 44 + func NewTracker(store *state.Store) *Tracker { 45 + return &Tracker{startedAt: time.Now(), store: store, pairs: map[string]PairResult{}} 46 + } 47 + 48 + // Record stores the outcome of a pass for one pair. Pass nil for a scope 49 + // that succeeded or wasn't enabled. 50 + func (t *Tracker) Record(name string, gitErr, issuesErr, patchErr error) { 51 + r := PairResult{Name: name, LastRun: time.Now()} 52 + if gitErr != nil { 53 + r.GitError = gitErr.Error() 54 + } 55 + if issuesErr != nil { 56 + r.IssuesError = issuesErr.Error() 57 + } 58 + if patchErr != nil { 59 + r.PatchError = patchErr.Error() 60 + } 61 + t.mu.Lock() 62 + defer t.mu.Unlock() 63 + t.pairs[name] = r 64 + } 65 + 66 + func (t *Tracker) snapshot() []PairResult { 67 + t.mu.Lock() 68 + defer t.mu.Unlock() 69 + out := make([]PairResult, 0, len(t.pairs)) 70 + for _, r := range t.pairs { 71 + out = append(out, r) 72 + } 73 + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 74 + return out 75 + } 76 + 77 + type statusResponse struct { 78 + StartedAt time.Time `json:"started_at"` 79 + Pairs []PairResult `json:"pairs"` 80 + } 81 + 82 + // Handler serves: 83 + // 84 + // GET /healthz - 200 if every pair's last pass was clean, 503 otherwise 85 + // GET /api/status - the JSON snapshot Record() has been fed 86 + // GET / - the activity dashboard (calendar + recent commits) 87 + func (t *Tracker) Handler() http.Handler { 88 + mux := http.NewServeMux() 89 + 90 + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { 91 + for _, p := range t.snapshot() { 92 + if !p.ok() { 93 + w.WriteHeader(http.StatusServiceUnavailable) 94 + return 95 + } 96 + } 97 + w.WriteHeader(http.StatusOK) 98 + }) 99 + 100 + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { 101 + resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} 102 + w.Header().Set("Content-Type", "application/json") 103 + json.NewEncoder(w).Encode(resp) 104 + }) 105 + 106 + mux.HandleFunc("/", t.serveDashboard) 107 + 108 + return mux 109 + } 110 + 111 + const dashboardDays = 90 112 + 113 + func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { 114 + since := time.Now().UTC().AddDate(0, 0, -dashboardDays).Truncate(24 * time.Hour) 115 + entries, err := t.store.ActivitySince(since) 116 + if err != nil { 117 + http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) 118 + return 119 + } 120 + 121 + data := dashboardData{ 122 + Pairs: t.snapshot(), 123 + Weeks: buildCalendar(entries, since), 124 + Commits: recentCommits(entries), 125 + } 126 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 127 + if err := dashboardTmpl.Execute(w, data); err != nil { 128 + http.Error(w, err.Error(), http.StatusInternalServerError) 129 + } 130 + } 131 + 132 + type dashboardData struct { 133 + Pairs []PairResult 134 + Weeks [][]day 135 + Commits []commitLine 136 + } 137 + 138 + type day struct { 139 + Date string // YYYY-MM-DD 140 + Count int 141 + Level int // 0-4, for the color ramp 142 + Tooltip string 143 + InRange bool 144 + } 145 + 146 + type commitLine struct { 147 + When string 148 + Pair string 149 + Message string 150 + } 151 + 152 + // buildCalendar buckets entries by day and lays them out GitHub-style: 153 + // one column per week, Sunday to Saturday down each column. 154 + func buildCalendar(entries []state.ActivityEntry, since time.Time) [][]day { 155 + byDay := map[string][]state.ActivityEntry{} 156 + for _, e := range entries { 157 + key := e.OccurredAt.Format("2006-01-02") 158 + byDay[key] = append(byDay[key], e) 159 + } 160 + 161 + start := since 162 + for start.Weekday() != time.Sunday { 163 + start = start.AddDate(0, 0, -1) 164 + } 165 + today := time.Now().UTC().Truncate(24 * time.Hour) 166 + 167 + var weeks [][]day 168 + var week []day 169 + for d := start; !d.After(today); d = d.AddDate(0, 0, 1) { 170 + key := d.Format("2006-01-02") 171 + es := byDay[key] 172 + week = append(week, day{ 173 + Date: key, 174 + Count: len(es), 175 + Level: levelFor(len(es)), 176 + Tooltip: tooltipFor(key, es), 177 + InRange: !d.Before(since), 178 + }) 179 + if d.Weekday() == time.Saturday { 180 + weeks = append(weeks, week) 181 + week = nil 182 + } 183 + } 184 + if len(week) > 0 { 185 + weeks = append(weeks, week) 186 + } 187 + return weeks 188 + } 189 + 190 + func levelFor(count int) int { 191 + switch { 192 + case count == 0: 193 + return 0 194 + case count <= 2: 195 + return 1 196 + case count <= 5: 197 + return 2 198 + case count <= 10: 199 + return 3 200 + default: 201 + return 4 202 + } 203 + } 204 + 205 + func tooltipFor(date string, es []state.ActivityEntry) string { 206 + if len(es) == 0 { 207 + return date + ": nothing synced" 208 + } 209 + byKind := map[string]int{} 210 + for _, e := range es { 211 + byKind[e.Kind]++ 212 + } 213 + var parts []string 214 + for _, k := range []string{"git", "issue", "patch"} { 215 + if n := byKind[k]; n > 0 { 216 + parts = append(parts, fmt.Sprintf("%d %s", n, plural(k, n))) 217 + } 218 + } 219 + return fmt.Sprintf("%s: %s", date, strings.Join(parts, ", ")) 220 + } 221 + 222 + func plural(word string, n int) string { 223 + if n == 1 { 224 + return word 225 + } 226 + if word == "patch" { 227 + return "patches" 228 + } 229 + return word + "s" 230 + } 231 + 232 + func recentCommits(entries []state.ActivityEntry) []commitLine { 233 + var out []commitLine 234 + for _, e := range entries { 235 + if e.Kind != "git" { 236 + continue 237 + } 238 + out = append(out, commitLine{ 239 + When: e.OccurredAt.Format("2006-01-02 15:04"), 240 + Pair: e.RepoPair, 241 + Message: e.Summary, 242 + }) 243 + } 244 + return out 245 + } 246 + 247 + var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html> 248 + <html lang="en"> 249 + <head> 250 + <meta charset="utf-8"> 251 + <title>graft</title> 252 + <style> 253 + :root { 254 + color-scheme: light; 255 + --surface: #fcfcfb; 256 + --text: #0b0b0b; 257 + --text-muted: #52514e; 258 + --border: #e4e2dc; 259 + --cell-0: #ebedf0; 260 + --cell-1: #cde2fb; 261 + --cell-2: #86b6ef; 262 + --cell-3: #3987e5; 263 + --cell-4: #184f95; 264 + --bad: #d03b3b; 265 + } 266 + @media (prefers-color-scheme: dark) { 267 + :root { 268 + color-scheme: dark; 269 + --surface: #1a1a19; 270 + --text: #ffffff; 271 + --text-muted: #c3c2b7; 272 + --border: #33322e; 273 + --cell-0: #22252a; 274 + --cell-1: #16324d; 275 + --cell-2: #1c5cab; 276 + --cell-3: #3987e5; 277 + --cell-4: #86b6ef; 278 + --bad: #e66767; 279 + } 280 + } 281 + * { box-sizing: border-box; } 282 + body { 283 + margin: 0; 284 + padding: 2.5rem 1.5rem; 285 + background: var(--surface); 286 + color: var(--text); 287 + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 288 + } 289 + main { max-width: 900px; margin: 0 auto; } 290 + h1 { font-size: 1.1rem; margin: 0 0 .25rem; } 291 + .sub { color: var(--text-muted); margin: 0 0 2rem; } 292 + 293 + .pairs { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 2rem; } 294 + .pair { 295 + border: 1px solid var(--border); border-radius: 6px; 296 + padding: .4rem .7rem; font-size: .8rem; display: flex; align-items: center; gap: .4rem; 297 + } 298 + .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cell-3); flex: none; } 299 + .dot.bad { background: var(--bad); } 300 + 301 + .calendar { display: flex; gap: 3px; margin-bottom: 2rem; overflow-x: auto; padding-bottom: .5rem; } 302 + .week { display: flex; flex-direction: column; gap: 3px; } 303 + .cell { 304 + width: 11px; height: 11px; border-radius: 2px; 305 + background: var(--cell-0); position: relative; cursor: default; 306 + } 307 + .cell[data-level="1"] { background: var(--cell-1); } 308 + .cell[data-level="2"] { background: var(--cell-2); } 309 + .cell[data-level="3"] { background: var(--cell-3); } 310 + .cell[data-level="4"] { background: var(--cell-4); } 311 + .cell[data-out-of-range="1"] { visibility: hidden; } 312 + 313 + .cell .tip { 314 + display: none; position: absolute; bottom: 150%; left: 50%; transform: translateX(-50%); 315 + background: var(--text); color: var(--surface); padding: .3rem .5rem; border-radius: 4px; 316 + font-size: .72rem; white-space: nowrap; z-index: 1; pointer-events: none; 317 + } 318 + .cell:hover .tip { display: block; } 319 + 320 + h2 { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .75rem; } 321 + .commits { border-top: 1px solid var(--border); } 322 + .commit { display: flex; gap: .75rem; padding: .5rem 0; border-bottom: 1px solid var(--border); font-size: .82rem; } 323 + .commit .when { color: var(--text-muted); flex: none; width: 11em; } 324 + .commit .pair { color: var(--text-muted); flex: none; width: 10em; } 325 + .commit .msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 326 + .empty { color: var(--text-muted); padding: 1rem 0; } 327 + </style> 328 + </head> 329 + <body> 330 + <main> 331 + <h1>graft</h1> 332 + <p class="sub">Forgejo &lt;-&gt; Radicle sync — last {{len .Weeks}} weeks</p> 333 + 334 + <div class="pairs"> 335 + {{range .Pairs}} 336 + <div class="pair"> 337 + <span class="dot{{if or .GitError .IssuesError .PatchError}} bad{{end}}"></span> 338 + {{.Name}} 339 + </div> 340 + {{else}} 341 + <span class="empty">no pairs configured</span> 342 + {{end}} 343 + </div> 344 + 345 + <div class="calendar"> 346 + {{range .Weeks}} 347 + <div class="week"> 348 + {{range .}} 349 + <div class="cell" data-level="{{.Level}}" data-out-of-range="{{if not .InRange}}1{{end}}"> 350 + <span class="tip">{{.Tooltip}}</span> 351 + </div> 352 + {{end}} 353 + </div> 354 + {{end}} 355 + </div> 356 + 357 + <h2>Recent commits</h2> 358 + <div class="commits"> 359 + {{range .Commits}} 360 + <div class="commit"> 361 + <span class="when">{{.When}}</span> 362 + <span class="pair">{{.Pair}}</span> 363 + <span class="msg">{{.Message}}</span> 364 + </div> 365 + {{else}} 366 + <p class="empty">nothing mirrored yet</p> 367 + {{end}} 368 + </div> 369 + </main> 370 + </body> 371 + </html> 372 + `))
+31 -5
internal/sync/git.go
··· 148 148 149 149 case radHead == "" || radHead == lastRad: 150 150 // Radicle hasn't moved since we last looked: forgejo is ahead. 151 - if err := g.pushBranch("forgejo", "rad"); err != nil { 151 + if err := g.pushBranch("forgejo", "rad", radHead, state.ForgejoToRadicle); err != nil { 152 152 return fmt.Errorf("mirror forgejo -> rad: %w", err) 153 153 } 154 154 155 155 case forgejoHead == "" || forgejoHead == lastForgejo: 156 156 // Forgejo hasn't moved: rad is ahead. 157 - if err := g.pushBranch("rad", "forgejo"); err != nil { 157 + if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil { 158 158 return fmt.Errorf("mirror rad -> forgejo: %w", err) 159 159 } 160 160 161 161 case g.isAncestor(lastForgejo, forgejoHead) && g.isAncestor(lastRad, radHead) && 162 162 g.isAncestor(radHead, forgejoHead): 163 163 // Both moved, but rad's tip is contained in forgejo's: forgejo wins. 164 - if err := g.pushBranch("forgejo", "rad"); err != nil { 164 + if err := g.pushBranch("forgejo", "rad", radHead, state.ForgejoToRadicle); err != nil { 165 165 return fmt.Errorf("mirror forgejo -> rad: %w", err) 166 166 } 167 167 168 168 case g.isAncestor(forgejoHead, radHead): 169 169 // Symmetric case: forgejo's tip is contained in rad's. 170 - if err := g.pushBranch("rad", "forgejo"); err != nil { 170 + if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil { 171 171 return fmt.Errorf("mirror rad -> forgejo: %w", err) 172 172 } 173 173 ··· 188 188 return g.State.SetGitCursor(g.RepoPair, forgejoHead, radHead) 189 189 } 190 190 191 - func (g *GitSyncer) pushBranch(from, to string) error { 191 + // pushBranch fetches g.ForgejoBranch from "from" and pushes it to "to". 192 + // oldToHead is that branch's head on "to" before the push (possibly ""), 193 + // used only to log which commits this pass actually moved, for the status 194 + // page's activity calendar. 195 + func (g *GitSyncer) pushBranch(from, to, oldToHead, direction string) error { 192 196 if _, err := g.run("fetch", "-q", from, g.ForgejoBranch); err != nil { 193 197 return fmt.Errorf("fetch %s: %w", from, err) 194 198 } 195 199 if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.ForgejoBranch); err != nil { 196 200 return fmt.Errorf("push %s: %w", to, err) 197 201 } 202 + g.logMirroredCommits(oldToHead, direction) 198 203 return nil 199 204 } 205 + 206 + // logMirroredCommits records one activity_log entry per commit newly 207 + // reachable from FETCH_HEAD that wasn't reachable from oldHead. Best-effort: 208 + // a failure here never fails the sync itself, it just leaves the status 209 + // page's calendar short an entry. 210 + func (g *GitSyncer) logMirroredCommits(oldHead, direction string) { 211 + rangeSpec := "FETCH_HEAD" 212 + if oldHead != "" { 213 + rangeSpec = oldHead + "..FETCH_HEAD" 214 + } 215 + out, err := g.run("log", "--pretty=format:%h %s", rangeSpec) 216 + if err != nil || out == "" { 217 + return 218 + } 219 + for _, line := range strings.Split(out, "\n") { 220 + if line == "" { 221 + continue 222 + } 223 + g.State.LogActivity(g.RepoPair, "git", direction, line) 224 + } 225 + }
+12 -4
internal/sync/issues.go
··· 60 60 return fmt.Errorf("open radicle issue: %w", err) 61 61 } 62 62 63 - return s.State.Upsert(s.RepoPair, state.ItemMapping{ 63 + if err := s.State.Upsert(s.RepoPair, state.ItemMapping{ 64 64 Kind: "issue", 65 65 ForgejoID: fi.Index, 66 66 RadicleID: radicleID, 67 67 ContentHash: hashText(fi.Title, fi.Body), 68 - }) 68 + }); err != nil { 69 + return err 70 + } 71 + s.State.LogActivity(s.RepoPair, "issue", state.ForgejoToRadicle, fi.Title) 72 + return nil 69 73 } 70 74 71 75 func (s *IssueSyncer) mirrorRadicleToForgejo(ri radicle.Issue) error { ··· 82 86 return fmt.Errorf("create forgejo issue: %w", err) 83 87 } 84 88 85 - return s.State.Upsert(s.RepoPair, state.ItemMapping{ 89 + if err := s.State.Upsert(s.RepoPair, state.ItemMapping{ 86 90 Kind: "issue", 87 91 ForgejoID: fi.Index, 88 92 RadicleID: ri.ID, 89 93 ContentHash: hashText(ri.Title, ri.Body()), 90 - }) 94 + }); err != nil { 95 + return err 96 + } 97 + s.State.LogActivity(s.RepoPair, "issue", state.RadicleToForgejo, ri.Title) 98 + return nil 91 99 } 92 100 93 101 func hashText(parts ...string) string {
+12 -4
internal/sync/patches.go
··· 106 106 return fmt.Errorf("could not parse patch id from: %q", out) 107 107 } 108 108 109 - return s.State.Upsert(s.RepoPair, state.ItemMapping{ 109 + if err := s.State.Upsert(s.RepoPair, state.ItemMapping{ 110 110 Kind: "patch", 111 111 ForgejoID: pr.Index, 112 112 RadicleID: m[1], 113 113 ContentHash: hashText(pr.Title, pr.Body), 114 - }) 114 + }); err != nil { 115 + return err 116 + } 117 + s.State.LogActivity(s.RepoPair, "patch", state.ForgejoToRadicle, pr.Title) 118 + return nil 115 119 } 116 120 117 121 func (s *PatchSyncer) mirrorRadicleToForgejo(p radicle.Patch) error { ··· 143 147 return fmt.Errorf("create forgejo pull request: %w", err) 144 148 } 145 149 146 - return s.State.Upsert(s.RepoPair, state.ItemMapping{ 150 + if err := s.State.Upsert(s.RepoPair, state.ItemMapping{ 147 151 Kind: "patch", 148 152 ForgejoID: pr.Index, 149 153 RadicleID: p.ID, 150 154 ContentHash: hashText(p.Title, latest.Description), 151 - }) 155 + }); err != nil { 156 + return err 157 + } 158 + s.State.LogActivity(s.RepoPair, "patch", state.RadicleToForgejo, p.Title) 159 + return nil 152 160 } 153 161 154 162 func localPRBranch(index int64) string {
+14 -8
internal/sync/syncer.go
··· 68 68 return rs, nil 69 69 } 70 70 71 + // Name is this pair's name, as given in the config. 72 + func (rs *RepoSyncer) Name() string { return rs.pair.Name } 73 + 71 74 // Run executes every enabled scope once, logging and continuing past 72 - // per-scope errors so one broken scope doesn't block the others. 73 - func (rs *RepoSyncer) Run(log *slog.Logger) { 75 + // per-scope errors so one broken scope doesn't block the others. It 76 + // returns each scope's error (nil if disabled or successful) for the 77 + // caller to report via internal/status. 78 + func (rs *RepoSyncer) Run(log *slog.Logger) (gitErr, issuesErr, patchErr error) { 74 79 log = log.With("pair", rs.pair.Name) 75 80 76 81 if rs.git != nil { 77 - if err := rs.git.Sync(); err != nil { 78 - log.Error("git sync failed", "err", err) 82 + if gitErr = rs.git.Sync(); gitErr != nil { 83 + log.Error("git sync failed", "err", gitErr) 79 84 } 80 85 } 81 86 if rs.issues != nil { 82 - if err := rs.issues.Sync(); err != nil { 83 - log.Error("issue sync failed", "err", err) 87 + if issuesErr = rs.issues.Sync(); issuesErr != nil { 88 + log.Error("issue sync failed", "err", issuesErr) 84 89 } 85 90 } 86 91 if rs.patches != nil { 87 - if err := rs.patches.Sync(); err != nil { 88 - log.Error("patch sync failed", "err", err) 92 + if patchErr = rs.patches.Sync(); patchErr != nil { 93 + log.Error("patch sync failed", "err", patchErr) 89 94 } 90 95 } 96 + return gitErr, issuesErr, patchErr 91 97 } 92 98 93 99 // authenticatedCloneURL embeds a token into an HTTPS clone URL the way