Federated via graft
0

Configure Feed

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

Group dashboard activity by repo, not by sync pair

Each repo pair now declares an optional series, grouping every side that
mirrors the same underlying repository (its Forgejo instances and Radicle
node) under one row. The dashboard heatmap becomes one event per cell
(never aggregated behind a single tooltip) inside a collapsible group per
repo, expanding to one sub-row per actual mirrored server with its own
link and its own events - instead of one ambiguous shared link.

Also fixes git commit activity always linking to Forgejo regardless of
which side the content actually landed on, so Radicle now shows up as a
real destination instead of being invisible in the activity feed.

+467 -178
+1 -1
cmd/sync/main.go
··· 56 56 runAll := func() { 57 57 for _, rs := range syncers { 58 58 gitErr, issuesErr, patchErr := rs.Run(log) 59 - tracker.Record(rs.Name(), gitErr, issuesErr, patchErr) 59 + tracker.Record(rs.Name(), rs.Series(), gitErr, issuesErr, patchErr) 60 60 } 61 61 } 62 62
+4
config.example.yaml
··· 10 10 11 11 repos: 12 12 - name: testrepo 13 + # Groups this pair with others that mirror the same underlying repo 14 + # (one pair per forge/Radicle side) under one row in the status 15 + # dashboard's heatmap. Defaults to `name` — its own row — if unset. 16 + series: testrepo 13 17 forgejo: 14 18 base_url: https://f1.cyberwild.org 15 19 owner: forgeadmin
+4
internal/config/config.go
··· 26 26 Forgejo ForgejoTarget `yaml:"forgejo"` 27 27 Radicle RadicleTarget `yaml:"radicle"` 28 28 Sync SyncScope `yaml:"sync"` 29 + // Series groups this pair with others that mirror the same underlying 30 + // repository (one per forge/Radicle side) under one row in the status 31 + // dashboard. Defaults to Name — i.e. its own row — if unset. 32 + Series string `yaml:"series"` 29 33 } 30 34 31 35 // ForgejoTarget identifies a repository on a Forgejo instance and where to
+41 -22
internal/state/store.go
··· 68 68 direction TEXT NOT NULL, -- 'forgejo_to_radicle' or 'radicle_to_forgejo' 69 69 summary TEXT NOT NULL, 70 70 url TEXT NOT NULL DEFAULT '', 71 + series TEXT NOT NULL DEFAULT '', 72 + series_url TEXT NOT NULL DEFAULT '', 71 73 occurred_at TEXT NOT NULL 72 74 ); 73 75 ··· 77 79 if err != nil { 78 80 return err 79 81 } 80 - // url was added after the table already shipped once; ALTER is a no-op 81 - // (ignored) on a fresh db where the CREATE above already included it. 82 - _, err = db.Exec(`ALTER TABLE activity_log ADD COLUMN url TEXT NOT NULL DEFAULT ''`) 83 - if err != nil && !strings.Contains(err.Error(), "duplicate column") { 84 - return err 82 + // These columns were added after the table already shipped once; each 83 + // ALTER is a no-op (ignored) on a fresh db where the CREATE above 84 + // already included it. 85 + for _, stmt := range []string{ 86 + `ALTER TABLE activity_log ADD COLUMN url TEXT NOT NULL DEFAULT ''`, 87 + `ALTER TABLE activity_log ADD COLUMN series TEXT NOT NULL DEFAULT ''`, 88 + `ALTER TABLE activity_log ADD COLUMN series_url TEXT NOT NULL DEFAULT ''`, 89 + } { 90 + if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { 91 + return err 92 + } 85 93 } 86 94 return nil 87 95 } ··· 155 163 return err 156 164 } 157 165 158 - // Direction names for LogActivity, kept as constants so callers can't typo 159 - // a value the status page's rendering silently fails to recognize. 166 + // Activity is one thing that was actually mirrored, for the status page. 167 + type Activity struct { 168 + RepoPair string // the sync pair that did it, e.g. "graft-test-v2-f1" 169 + Series string // the human-facing repo group this pair belongs to, 170 + // e.g. "graft-test-v2" — several pairs (one per forge/Radicle side) 171 + // can share a series so the dashboard shows one row per repo, not 172 + // one per pair. 173 + SeriesURL string // where clicking the series' own name should go 174 + Kind string // "git", "issue", "patch" 175 + Direction string // ForgejoToRadicle or RadicleToForgejo 176 + Summary string 177 + URL string // where to send someone who clicks this entry — the 178 + // forge or Radicle page for the thing that now exists because of 179 + // this sync; "" if none is known. 180 + } 181 + 182 + // Direction values for Activity.Direction, kept as constants so callers 183 + // can't typo a value the status page's rendering silently fails to 184 + // recognize. 160 185 const ( 161 186 ForgejoToRadicle = "forgejo_to_radicle" 162 187 RadicleToForgejo = "radicle_to_forgejo" 163 188 ) 164 189 165 - // LogActivity records one thing that was actually mirrored, for the status 166 - // page. Call it once per commit/issue/patch, not once per sync pass. url is 167 - // where to send someone who clicks this entry — the forge or Radicle page 168 - // for the thing that now exists because of this sync; "" if none is known. 169 - func (s *Store) LogActivity(repoPair, kind, direction, summary, url string) error { 190 + // LogActivity records one Activity. Call it once per commit/issue/patch, 191 + // not once per sync pass. 192 + func (s *Store) LogActivity(a Activity) error { 170 193 _, err := s.db.Exec(` 171 - INSERT INTO activity_log (repo_pair, kind, direction, summary, url, occurred_at) 172 - VALUES (?, ?, ?, ?, ?, ?) 173 - `, repoPair, kind, direction, summary, url, time.Now().UTC().Format(time.RFC3339)) 194 + INSERT INTO activity_log (repo_pair, kind, direction, summary, url, series, series_url, occurred_at) 195 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 196 + `, a.RepoPair, a.Kind, a.Direction, a.Summary, a.URL, a.Series, a.SeriesURL, time.Now().UTC().Format(time.RFC3339)) 174 197 return err 175 198 } 176 199 177 200 // ActivityEntry is one row of activity_log. 178 201 type ActivityEntry struct { 179 - RepoPair string 180 - Kind string 181 - Direction string 182 - Summary string 183 - URL string 202 + Activity 184 203 OccurredAt time.Time 185 204 } 186 205 187 206 // ActivitySince returns every activity entry at or after since, newest first. 188 207 func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) { 189 208 rows, err := s.db.Query(` 190 - SELECT repo_pair, kind, direction, summary, url, occurred_at FROM activity_log 209 + SELECT repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 191 210 WHERE occurred_at >= ? 192 211 ORDER BY occurred_at DESC 193 212 `, since.UTC().Format(time.RFC3339)) ··· 200 219 for rows.Next() { 201 220 var e ActivityEntry 202 221 var occurredAt string 203 - if err := rows.Scan(&e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &occurredAt); err != nil { 222 + if err := rows.Scan(&e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &occurredAt); err != nil { 204 223 return nil, err 205 224 } 206 225 e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt)
+347 -145
internal/status/status.go
··· 8 8 "encoding/json" 9 9 "html/template" 10 10 "net/http" 11 + "net/url" 11 12 "sort" 13 + "strings" 12 14 "sync" 13 15 "time" 14 16 ··· 19 21 // error string means that scope is either disabled or succeeded. 20 22 type PairResult struct { 21 23 Name string `json:"name"` 24 + Series string `json:"series"` 22 25 LastRun time.Time `json:"last_run"` 23 26 GitError string `json:"git_error,omitempty"` 24 27 IssuesError string `json:"issues_error,omitempty"` ··· 45 48 } 46 49 47 50 // Record stores the outcome of a pass for one pair. Pass nil for a scope 48 - // that succeeded or wasn't enabled. 49 - func (t *Tracker) Record(name string, gitErr, issuesErr, patchErr error) { 50 - r := PairResult{Name: name, LastRun: time.Now()} 51 + // that succeeded or wasn't enabled. series is the dashboard row this pair's 52 + // status badge groups under (see config.RepoPair.Series). 53 + func (t *Tracker) Record(name, series string, gitErr, issuesErr, patchErr error) { 54 + r := PairResult{Name: name, Series: series, LastRun: time.Now()} 51 55 if gitErr != nil { 52 56 r.GitError = gitErr.Error() 53 57 } ··· 107 111 return mux 108 112 } 109 113 110 - const dashboardDays = 90 114 + const dashboardDays = 3 111 115 112 116 func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { 113 - since := time.Now().UTC().AddDate(0, 0, -dashboardDays).Truncate(24 * time.Hour) 117 + since := time.Now().UTC().AddDate(0, 0, -(dashboardDays - 1)).Truncate(24 * time.Hour) 114 118 entries, err := t.store.ActivitySince(since) 115 119 if err != nil { 116 120 http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) 117 121 return 118 122 } 119 123 124 + pairs := groupPairStatus(t.snapshot()) 125 + health := make(map[string]bool, len(pairs)) 126 + for _, p := range pairs { 127 + health[p.Name] = p.OK 128 + } 129 + 120 130 data := dashboardData{ 121 - Pairs: t.snapshot(), 122 - Weeks: buildCalendar(entries, since), 123 - Commits: recentCommits(entries), 131 + Series: buildSeriesRows(entries, health), 132 + Activity: recentActivity(entries), 124 133 SourceURL: t.sourceURL, 125 134 } 126 135 w.Header().Set("Content-Type", "text/html; charset=utf-8") 136 + w.Header().Set("Cache-Control", "no-store") 127 137 if err := dashboardTmpl.Execute(w, data); err != nil { 128 138 http.Error(w, err.Error(), http.StatusInternalServerError) 129 139 } 130 140 } 131 141 132 142 type dashboardData struct { 133 - Pairs []PairResult 134 - Weeks [][]day 135 - Commits []commitLine 143 + Series []seriesRow 144 + Activity []commitLine 136 145 SourceURL string 137 146 } 138 147 139 - type day struct { 140 - Date string // YYYY-MM-DD, human-readable 141 - Count int 142 - Level int // 0-4, for the color ramp 143 - Events []event 144 - InRange bool 148 + // pairStatus is one badge in the dashboard's top status card: one per 149 + // series (repo), OK only if every pair mirroring that repo's last pass was 150 + // clean — grouped so a repo with 3 mirrored sides (e.g. two Forgejos plus 151 + // an external instance) shows one badge, not three. 152 + type pairStatus struct { 153 + Name string 154 + OK bool 155 + } 156 + 157 + func groupPairStatus(results []PairResult) []pairStatus { 158 + order := []string{} 159 + ok := map[string]bool{} 160 + for _, r := range results { 161 + name := r.Series 162 + if name == "" { 163 + name = r.Name 164 + } 165 + if _, seen := ok[name]; !seen { 166 + order = append(order, name) 167 + ok[name] = true 168 + } 169 + if !r.ok() { 170 + ok[name] = false 171 + } 172 + } 173 + sort.Strings(order) 174 + out := make([]pairStatus, 0, len(order)) 175 + for _, name := range order { 176 + out = append(out, pairStatus{Name: name, OK: ok[name]}) 177 + } 178 + return out 179 + } 180 + 181 + // seriesRow is one full-width strip of the activity heatmap: everything 182 + // mirrored for one underlying repository (grouped across its Forgejo and 183 + // Radicle sides via config.RepoPair.Series), one cell per actual event — 184 + // not one per day — so nothing is ever aggregated behind a single tooltip. 185 + type seriesRow struct { 186 + Name string 187 + OK bool 188 + SubRows []subRow 189 + } 190 + 191 + // subRow is one mirrored side of a series — one real repo (a specific 192 + // Forgejo instance, or a specific Radicle node) — shown once the series is 193 + // expanded, with only that side's own events and its own link. A series 194 + // with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never 195 + // one link picked arbitrarily to represent all of them. 196 + type subRow struct { 197 + Label string 198 + URL string 199 + Events []event 145 200 } 146 201 147 - // event is one activity_log row, shaped for the tooltip's clickable list. 202 + // event is one activity_log row: one heatmap cell, one tooltip, one link. 148 203 type event struct { 149 - Label string // "3 commits", "1 issue", etc. joined for the summary line 150 - Text string 151 - URL string 204 + Kind string // "git", "issue", "patch" — drives the cell's color 205 + Label string // "Commit", "Issue", "Patch" 206 + Text string 207 + URL string 208 + Server string // which mirrored side this happened on, e.g. "f1", "alice" 209 + When string // human-readable timestamp, for the tooltip 152 210 } 153 211 154 212 type commitLine struct { 155 213 When string 156 214 Pair string 215 + PairURL string 216 + Server string 217 + Kind string // "Commit", "Issue", "Patch" 157 218 Message string 158 219 URL string 159 220 } 160 221 161 - // buildCalendar buckets entries by day and lays them out GitHub-style: 162 - // one column per week, Sunday to Saturday down each column. 163 - func buildCalendar(entries []state.ActivityEntry, since time.Time) [][]day { 164 - byDay := map[string][]state.ActivityEntry{} 165 - for _, e := range entries { 166 - key := e.OccurredAt.Format("2006-01-02") 167 - byDay[key] = append(byDay[key], e) 222 + // serverLabel names which side of a series a repo_pair belongs to, e.g. 223 + // "federation-x-f1" under series "federation-x" becomes "f1". Several pairs 224 + // share one series row/badge, so without this an operator can't tell which 225 + // mirrored server (f1, f2, an external instance...) a given event actually 226 + // happened on. Empty if the pair name doesn't start with its series name. 227 + func serverLabel(repoPair, series string) string { 228 + if series == "" { 229 + return "" 168 230 } 169 - 170 - start := since 171 - for start.Weekday() != time.Sunday { 172 - start = start.AddDate(0, 0, -1) 231 + suffix := strings.TrimPrefix(repoPair, series+"-") 232 + if suffix == repoPair { 233 + return "" 173 234 } 174 - today := time.Now().UTC().Truncate(24 * time.Hour) 235 + return suffix 236 + } 175 237 176 - var weeks [][]day 177 - var week []day 178 - for d := start; !d.After(today); d = d.AddDate(0, 0, 1) { 179 - key := d.Format("2006-01-02") 180 - es := byDay[key] 181 - week = append(week, day{ 182 - Date: d.Format("Jan 2, 2006"), 183 - Count: len(es), 184 - Level: levelFor(len(es)), 185 - Events: toEvents(es), 186 - InRange: !d.Before(since), 187 - }) 188 - if d.Weekday() == time.Saturday { 189 - weeks = append(weeks, week) 190 - week = nil 191 - } 238 + // hostLabel shortens a URL's host to its canonical name for compact 239 + // display: the TLD is dropped ("f1.cyberwild.org" -> "f1.cyberwild", 240 + // "artefacts.bimr.net" -> "artefacts.bimr"), so every server — ours or an 241 + // external instance's — reads the same way, never a per-pair alias. 242 + func hostLabel(rawURL string) string { 243 + u, err := url.Parse(rawURL) 244 + if err != nil || u.Host == "" { 245 + return "" 192 246 } 193 - if len(week) > 0 { 194 - weeks = append(weeks, week) 247 + parts := strings.Split(u.Host, ".") 248 + if len(parts) > 1 { 249 + parts = parts[:len(parts)-1] 195 250 } 196 - return weeks 251 + return strings.Join(parts, ".") 197 252 } 198 253 199 - func levelFor(count int) int { 200 - switch { 201 - case count == 0: 202 - return 0 203 - case count <= 2: 204 - return 1 205 - case count <= 5: 206 - return 2 207 - case count <= 10: 208 - return 3 209 - default: 210 - return 4 254 + // buildSeriesRows groups entries by their Series (one row per monitored 255 + // repo, spanning both its Forgejo and Radicle sides) — Atlassian 256 + // Statuspage style: one full-width horizontal strip per series. Each entry 257 + // becomes its own cell, oldest first, so a busy day never hides events 258 + // behind one shared tooltip. 259 + func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool) []seriesRow { 260 + type subAcc struct { 261 + url string 262 + events []state.ActivityEntry 263 + } 264 + type seriesAcc struct { 265 + subOrder []string 266 + subs map[string]*subAcc 267 + } 268 + seriesAccs := map[string]*seriesAcc{} 269 + var seriesOrder []string 270 + for _, e := range entries { 271 + name := e.Series 272 + if name == "" { 273 + name = e.RepoPair 274 + } 275 + sa, ok := seriesAccs[name] 276 + if !ok { 277 + sa = &seriesAcc{subs: map[string]*subAcc{}} 278 + seriesAccs[name] = sa 279 + seriesOrder = append(seriesOrder, name) 280 + } 281 + server := hostLabel(e.URL) 282 + if server == "" { 283 + server = serverLabel(e.RepoPair, e.Series) 284 + } 285 + if server == "" { 286 + server = e.RepoPair 287 + } 288 + sub, ok := sa.subs[server] 289 + if !ok { 290 + sub = &subAcc{} 291 + sa.subs[server] = sub 292 + sa.subOrder = append(sa.subOrder, server) 293 + } 294 + if sub.url == "" { 295 + sub.url = repoRootURL(e.URL) 296 + } 297 + sub.events = append(sub.events, e) 298 + } 299 + sort.Strings(seriesOrder) 300 + 301 + rows := make([]seriesRow, 0, len(seriesOrder)) 302 + for _, name := range seriesOrder { 303 + sa := seriesAccs[name] 304 + subOrder := append([]string(nil), sa.subOrder...) 305 + sort.Strings(subOrder) 306 + 307 + subRows := make([]subRow, 0, len(subOrder)) 308 + for _, label := range subOrder { 309 + sub := sa.subs[label] 310 + // entries arrive newest-first (see ActivitySince); the 311 + // heatmap reads left-to-right as oldest-to-newest. 312 + es := make([]state.ActivityEntry, len(sub.events)) 313 + copy(es, sub.events) 314 + sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) 315 + subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es)}) 316 + } 317 + 318 + ok, known := health[name] 319 + if !known { 320 + ok = true 321 + } 322 + rows = append(rows, seriesRow{Name: name, OK: ok, SubRows: subRows}) 211 323 } 324 + return rows 212 325 } 213 326 214 327 func toEvents(es []state.ActivityEntry) []event { 215 328 out := make([]event, 0, len(es)) 216 329 for _, e := range es { 330 + server := hostLabel(e.URL) 331 + if server == "" { 332 + server = serverLabel(e.RepoPair, e.Series) 333 + } 217 334 out = append(out, event{ 218 - Label: kindLabel(e.Kind), 219 - Text: e.Summary, 220 - URL: e.URL, 335 + Kind: e.Kind, 336 + Label: kindLabel(e.Kind), 337 + Text: e.Summary, 338 + URL: e.URL, 339 + Server: server, 340 + When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), 221 341 }) 222 342 } 223 343 return out ··· 236 356 } 237 357 } 238 358 239 - func recentCommits(entries []state.ActivityEntry) []commitLine { 240 - var out []commitLine 359 + // recentActivity lists every mirrored event — commits, issues, and 360 + // patches/pull requests alike, newest first. graft doesn't mirror stars or 361 + // other social metadata: its sync scope is git content, issues, and 362 + // patches/PRs only (see config.SyncScope), so that's what shows up here. 363 + func recentActivity(entries []state.ActivityEntry) []commitLine { 364 + out := make([]commitLine, 0, len(entries)) 241 365 for _, e := range entries { 242 - if e.Kind != "git" { 243 - continue 366 + name := e.Series 367 + if name == "" { 368 + name = e.RepoPair 369 + } 370 + server := hostLabel(e.URL) 371 + if server == "" { 372 + server = serverLabel(e.RepoPair, e.Series) 373 + } 374 + // The (server) parenthetical should point at that specific 375 + // server's own repo, not the series' shared Radicle explorer 376 + // link (which is fixed to whichever pair logged first). 377 + pairURL := repoRootURL(e.URL) 378 + if pairURL == "" { 379 + pairURL = e.SeriesURL 244 380 } 245 381 out = append(out, commitLine{ 246 382 When: e.OccurredAt.Format("Jan 2, 15:04"), 247 - Pair: e.RepoPair, 383 + Pair: name, 384 + PairURL: pairURL, 385 + Server: server, 386 + Kind: kindLabel(e.Kind), 248 387 Message: e.Summary, 249 388 URL: e.URL, 250 389 }) ··· 252 391 return out 253 392 } 254 393 394 + // repoRootURL trims an event's specific-item URL (a commit, issue, or 395 + // patch/PR page) back down to its repository root, e.g. 396 + // ".../owner/repo/commit/<sha>" -> ".../owner/repo". Empty if rawURL is 397 + // empty or doesn't contain a recognized item path. 398 + func repoRootURL(rawURL string) string { 399 + for _, marker := range []string{"/commit/", "/issues/", "/pulls/", "/patches/"} { 400 + if idx := strings.Index(rawURL, marker); idx >= 0 { 401 + return rawURL[:idx] 402 + } 403 + } 404 + return "" 405 + } 406 + 255 407 var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html> 256 408 <html lang="en"> 257 409 <head> ··· 268 420 --border: #dfe1e6; 269 421 --brand: #0052cc; 270 422 --brand-dark: #0747a6; 271 - --cell-0: #f4f5f7; 272 - --cell-1: #cde2fb; 273 - --cell-2: #86b6ef; 274 - --cell-3: #3987e5; 275 - --cell-4: #184f95; 423 + --kind-git: #0052cc; 424 + --kind-issue: #00875a; 425 + --kind-patch: #6554c0; 276 426 --bad: #de350b; 277 427 --good: #36b37e; 278 428 } ··· 297 447 margin-bottom: 1.25rem; 298 448 } 299 449 300 - .pairs { display: flex; flex-wrap: wrap; gap: .5rem; } 301 - .pair { 302 - border: 1px solid var(--border); border-radius: 3px; background: var(--surface-sunk); 303 - padding: .35rem .65rem; font-size: .78rem; display: flex; align-items: center; gap: .4rem; 304 - } 305 - .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); flex: none; } 450 + .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; display: inline-block; } 306 451 .dot.bad { background: var(--bad); } 307 452 308 - .calendar-scroll { overflow-x: auto; overflow-y: visible; padding: 20px 0 4px; } 309 - .calendar { display: flex; gap: 3px; width: max-content; } 310 - .week { display: flex; flex-direction: column; gap: 3px; } 453 + .heatmap-head { display: flex; align-items: baseline; justify-content: space-between; } 454 + .heatmap-head h2 { margin: 0; } 455 + .legend { display: flex; gap: .9rem; margin-bottom: .9rem; } 456 + .legend span { display: flex; align-items: center; gap: .35rem; font-size: .74rem; color: var(--text-muted); } 457 + .legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } 458 + .legend .sw[data-kind="git"] { background: var(--kind-git); } 459 + .legend .sw[data-kind="issue"] { background: var(--kind-issue); } 460 + .legend .sw[data-kind="patch"] { background: var(--kind-patch); } 461 + .heatmap { display: flex; flex-direction: column; gap: .15rem; } 462 + .series-group { border-bottom: 1px solid var(--border); } 463 + .series-group:last-child { border-bottom: none; } 464 + .series-group[open] { padding-bottom: .5rem; } 465 + .series-label { 466 + display: flex; align-items: center; gap: .45rem; padding: .55rem 0; 467 + cursor: pointer; list-style: none; 468 + } 469 + .series-label::-webkit-details-marker { display: none; } 470 + .series-label::before { 471 + content: ""; width: 0; height: 0; flex: none; 472 + border-style: solid; border-width: 4px 0 4px 5px; 473 + border-color: transparent transparent transparent var(--text-muted); 474 + transition: transform .12s ease; 475 + } 476 + .series-group[open] > .series-label::before { transform: rotate(90deg); } 477 + .series-name { font-size: .82rem; font-weight: 600; color: var(--text); } 478 + .series-count { font-size: .74rem; color: var(--text-muted); } 479 + .subrows { display: flex; flex-direction: column; gap: .5rem; padding-left: 1.3rem; } 480 + .subrow { display: flex; align-items: center; gap: .75rem; } 481 + .subrow-name { 482 + flex: 0 0 11rem; font-size: .78rem; color: var(--text-muted); text-decoration: none; 483 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 484 + } 485 + a.subrow-name { color: var(--brand); } 486 + a.subrow-name:hover { text-decoration: underline; } 487 + .days { display: flex; gap: 3px; flex-wrap: wrap; } 311 488 .cell { 312 - width: 12px; height: 12px; border-radius: 2px; 313 - background: var(--cell-0); position: relative; cursor: default; 489 + flex: none; width: 10px; height: 22px; border-radius: 3px; 490 + background: var(--border); position: relative; cursor: default; 314 491 } 315 - .cell[data-level="1"] { background: var(--cell-1); } 316 - .cell[data-level="2"] { background: var(--cell-2); } 317 - .cell[data-level="3"] { background: var(--cell-3); } 318 - .cell[data-level="4"] { background: var(--cell-4); } 319 - .cell[data-out-of-range="1"] { visibility: hidden; } 492 + .cell[data-kind="git"] { background: var(--kind-git); } 493 + .cell[data-kind="issue"] { background: var(--kind-issue); } 494 + .cell[data-kind="patch"] { background: var(--kind-patch); } 320 495 321 496 .tip { 322 - display: none; position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); 497 + display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%); 323 498 background: var(--text); color: #fff; border-radius: 6px; 324 - font-size: .78rem; white-space: nowrap; z-index: 10; padding: .5rem 0; 499 + font-size: .78rem; z-index: 20; padding: .5rem 0; 325 500 box-shadow: 0 4px 12px rgba(9,30,66,.25); 501 + width: max-content; max-width: min(320px, 90vw); 326 502 } 503 + .cell:nth-child(-n+2) .tip { left: 0; transform: none; } 504 + .cell:nth-last-child(-n+2) .tip { left: auto; right: 0; transform: none; } 327 505 .cell:hover .tip, .tip:hover { display: block; } 328 506 .tip .tip-date { padding: 0 .75rem .35rem; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.15); margin-bottom: .35rem; } 329 507 .tip a.tip-row { 330 - display: block; padding: .3rem .75rem; color: #fff; text-decoration: none; white-space: nowrap; 508 + display: flex; gap: .4em; padding: .3rem .75rem; color: #fff; text-decoration: none; 509 + white-space: normal; overflow-wrap: anywhere; line-height: 1.35; 331 510 } 332 511 .tip a.tip-row:hover { background: rgba(255,255,255,.12); } 333 - .tip .tip-kind { color: #b3bac5; margin-right: .4em; } 512 + .tip .tip-kind { color: #b3bac5; flex: none; } 334 513 .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; } 335 514 336 515 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; } 337 516 .commits { border-top: 1px solid var(--border); } 338 - a.commit { 339 - display: flex; gap: .75rem; padding: .55rem 0; border-bottom: 1px solid var(--border); 340 - font-size: .82rem; color: inherit; text-decoration: none; 517 + .commit { 518 + display: flex; align-items: center; gap: .75rem; padding: .55rem 0; border-bottom: 1px solid var(--border); 519 + font-size: .82rem; 341 520 } 342 - a.commit:hover .msg { color: var(--brand); text-decoration: underline; } 343 521 .commit .when { color: var(--text-muted); flex: none; width: 9em; } 344 - .commit .pair { color: var(--text-muted); flex: none; width: 10em; } 345 - .commit .msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; } 522 + .commit .kind { color: var(--text-muted); flex: none; width: 4.5em; font-size: .74rem; text-transform: uppercase; letter-spacing: .03em; } 523 + .commit .pair { 524 + color: var(--text-muted); flex: 0 1 auto; max-width: 22em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 525 + text-decoration: none; 526 + } 527 + .commit a.pair { color: var(--brand); } 528 + .commit a.pair:hover { text-decoration: underline; } 529 + .commit .msg { 530 + flex: 1 1 auto; min-width: 0; 531 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; 532 + color: inherit; text-decoration: none; 533 + } 534 + .commit a.msg { color: var(--brand); } 535 + .commit a.msg:hover { text-decoration: underline; } 346 536 .empty { color: var(--text-muted); padding: .5rem 0; } 347 537 348 538 footer { ··· 356 546 <main> 357 547 <div class="top"> 358 548 <h1>graft</h1> 359 - <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 90 days</span> 549 + <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 3 days</span> 360 550 </div> 361 551 362 552 <div class="card"> 363 - <div class="pairs"> 364 - {{range .Pairs}} 365 - <div class="pair"> 366 - <span class="dot{{if or .GitError .IssuesError .PatchError}} bad{{end}}"></span> 367 - {{.Name}} 553 + <div class="heatmap-head"> 554 + <h2>Activity by repo</h2> 555 + <div class="legend"> 556 + <span><i class="sw" data-kind="git"></i>Commit</span> 557 + <span><i class="sw" data-kind="issue"></i>Issue</span> 558 + <span><i class="sw" data-kind="patch"></i>Patch</span> 368 559 </div> 369 - {{else}} 370 - <span class="empty">no pairs configured</span> 371 - {{end}} 372 560 </div> 373 - </div> 374 - 375 - <div class="card"> 376 - <div class="calendar-scroll"> 377 - <div class="calendar"> 378 - {{range .Weeks}} 379 - <div class="week"> 380 - {{range .}} 381 - <div class="cell" data-level="{{.Level}}" data-out-of-range="{{if not .InRange}}1{{end}}"> 382 - <div class="tip"> 383 - <div class="tip-date">{{.Date}}</div> 384 - {{range .Events}} 385 - {{if .URL}} 386 - <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a> 387 - {{else}} 388 - <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span> 389 - {{end}} 390 - {{else}} 391 - <span class="tip-empty">nothing synced</span> 392 - {{end}} 561 + <div class="heatmap"> 562 + {{range .Series}} 563 + <details class="series-group"> 564 + <summary class="series-label"> 565 + <span class="dot{{if not .OK}} bad{{end}}"></span> 566 + <span class="series-name">{{.Name}}</span> 567 + <span class="series-count">{{len .SubRows}} mirrored side{{if ne (len .SubRows) 1}}s{{end}}</span> 568 + </summary> 569 + <div class="subrows"> 570 + {{range .SubRows}} 571 + <div class="subrow"> 572 + {{if .URL}} 573 + <a class="subrow-name" href="{{.URL}}" target="_blank" rel="noopener">{{.Label}}</a> 574 + {{else}} 575 + <span class="subrow-name">{{.Label}}</span> 576 + {{end}} 577 + <div class="days"> 578 + {{range .Events}} 579 + <div class="cell" data-kind="{{.Kind}}"> 580 + <div class="tip"> 581 + <div class="tip-date">{{.When}}</div> 582 + {{if .URL}} 583 + <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a> 584 + {{else}} 585 + <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span> 586 + {{end}} 587 + </div> 588 + </div> 589 + {{else}} 590 + <span class="tip-empty">nothing synced</span> 591 + {{end}} 393 592 </div> 394 593 </div> 395 594 {{end}} 396 595 </div> 397 - {{end}} 398 - </div> 596 + </details> 597 + {{else}} 598 + <span class="empty">nothing synced yet</span> 599 + {{end}} 399 600 </div> 400 601 </div> 401 602 402 603 <div class="card"> 403 - <h2>Recent commits</h2> 604 + <h2>Recent activity</h2> 404 605 <div class="commits"> 405 - {{range .Commits}} 406 - {{if .URL}} 407 - <a class="commit" href="{{.URL}}" target="_blank" rel="noopener"> 408 - <span class="when">{{.When}}</span> 409 - <span class="pair">{{.Pair}}</span> 410 - <span class="msg">{{.Message}}</span> 411 - </a> 412 - {{else}} 606 + {{range .Activity}} 413 607 <div class="commit"> 414 608 <span class="when">{{.When}}</span> 415 - <span class="pair">{{.Pair}}</span> 609 + <span class="kind">{{.Kind}}</span> 610 + {{if .PairURL}} 611 + <a class="pair" href="{{.PairURL}}" target="_blank" rel="noopener">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</a> 612 + {{else}} 613 + <span class="pair">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</span> 614 + {{end}} 615 + {{if .URL}} 616 + <a class="msg" href="{{.URL}}" target="_blank" rel="noopener">{{.Message}}</a> 617 + {{else}} 416 618 <span class="msg">{{.Message}}</span> 619 + {{end}} 417 620 </div> 418 - {{end}} 419 621 {{else}} 420 622 <p class="empty">nothing mirrored yet</p> 421 623 {{end}}
+22 -6
internal/sync/git.go
··· 27 27 WorkDir string // local clone, created on first run if absent 28 28 ForgejoURL string // authenticated clone URL (token embedded), for git 29 29 ForgejoWebURL string // https://host/owner/repo, for links in the UI 30 + RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 30 31 ForgejoBranch string 31 32 RID string 32 33 RadHome string 33 34 State *state.Store 35 + Series string // dashboard row this pair's activity groups under 36 + SeriesURL string // where clicking that row's name goes 34 37 } 35 38 36 39 func (g *GitSyncer) run(args ...string) (string, error) { ··· 200 203 if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.ForgejoBranch); err != nil { 201 204 return fmt.Errorf("push %s: %w", to, err) 202 205 } 203 - g.logMirroredCommits(oldToHead, direction) 206 + g.logMirroredCommits(oldToHead, direction, to) 204 207 return nil 205 208 } 206 209 207 210 // logMirroredCommits records one activity_log entry per commit newly 208 211 // reachable from FETCH_HEAD that wasn't reachable from oldHead. Best-effort: 209 212 // a failure here never fails the sync itself, it just leaves the status 210 - // page's calendar short an entry. 211 - func (g *GitSyncer) logMirroredCommits(oldHead, direction string) { 213 + // page's calendar short an entry. to is "forgejo" or "rad" — the remote the 214 + // commits were just pushed to — so the logged link (and the dashboard's 215 + // derived server label) points at where the content actually landed, 216 + // instead of always pointing at Forgejo regardless of direction. 217 + func (g *GitSyncer) logMirroredCommits(oldHead, direction, to string) { 212 218 rangeSpec := "FETCH_HEAD" 213 219 if oldHead != "" { 214 220 rangeSpec = oldHead + "..FETCH_HEAD" ··· 229 235 continue 230 236 } 231 237 url := "" 232 - if g.ForgejoWebURL != "" { 233 - url = g.ForgejoWebURL + "/commit/" + full 238 + switch to { 239 + case "rad": 240 + if g.RadicleWebURL != "" { 241 + url = g.RadicleWebURL + "/commits/" + full 242 + } 243 + default: 244 + if g.ForgejoWebURL != "" { 245 + url = g.ForgejoWebURL + "/commit/" + full 246 + } 234 247 } 235 - g.State.LogActivity(g.RepoPair, "git", direction, summary, url) 248 + g.State.LogActivity(state.Activity{ 249 + RepoPair: g.RepoPair, Series: g.Series, SeriesURL: g.SeriesURL, 250 + Kind: "git", Direction: direction, Summary: summary, URL: url, 251 + }) 236 252 } 237 253 }
+12 -2
internal/sync/issues.go
··· 23 23 State *state.Store 24 24 ForgejoWebURL string // https://host/owner/repo, for links in the UI 25 25 RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 26 + Series string // dashboard row this pair's activity groups under 27 + SeriesURL string // where clicking that row's name goes 26 28 } 27 29 28 30 func (s *IssueSyncer) Sync() error { ··· 70 72 }); err != nil { 71 73 return err 72 74 } 73 - s.State.LogActivity(s.RepoPair, "issue", state.ForgejoToRadicle, fi.Title, s.RadicleWebURL+"/issues/"+radicleID) 75 + s.State.LogActivity(state.Activity{ 76 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 77 + Kind: "issue", Direction: state.ForgejoToRadicle, Summary: fi.Title, 78 + URL: s.RadicleWebURL + "/issues/" + radicleID, 79 + }) 74 80 return nil 75 81 } 76 82 ··· 96 102 }); err != nil { 97 103 return err 98 104 } 99 - s.State.LogActivity(s.RepoPair, "issue", state.RadicleToForgejo, ri.Title, fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, fi.Index)) 105 + s.State.LogActivity(state.Activity{ 106 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 107 + Kind: "issue", Direction: state.RadicleToForgejo, Summary: ri.Title, 108 + URL: fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, fi.Index), 109 + }) 100 110 return nil 101 111 } 102 112
+12 -2
internal/sync/patches.go
··· 26 26 State *state.Store 27 27 ForgejoWebURL string // https://host/owner/repo, for links in the UI 28 28 RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 29 + Series string // dashboard row this pair's activity groups under 30 + SeriesURL string // where clicking that row's name goes 29 31 } 30 32 31 33 func (s *PatchSyncer) git(args ...string) (string, error) { ··· 116 118 }); err != nil { 117 119 return err 118 120 } 119 - s.State.LogActivity(s.RepoPair, "patch", state.ForgejoToRadicle, pr.Title, s.RadicleWebURL+"/patches/"+m[1]) 121 + s.State.LogActivity(state.Activity{ 122 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 123 + Kind: "patch", Direction: state.ForgejoToRadicle, Summary: pr.Title, 124 + URL: s.RadicleWebURL + "/patches/" + m[1], 125 + }) 120 126 return nil 121 127 } 122 128 ··· 157 163 }); err != nil { 158 164 return err 159 165 } 160 - s.State.LogActivity(s.RepoPair, "patch", state.RadicleToForgejo, p.Title, fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index)) 166 + s.State.LogActivity(state.Activity{ 167 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 168 + Kind: "patch", Direction: state.RadicleToForgejo, Summary: p.Title, 169 + URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index), 170 + }) 161 171 return nil 162 172 } 163 173
+24
internal/sync/syncer.go
··· 44 44 forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 45 45 radicleWebURL := radicleExplorerLink(pair.Radicle) 46 46 47 + series := pair.Series 48 + if series == "" { 49 + series = pair.Name 50 + } 51 + seriesURL := radicleWebURL 52 + if seriesURL == "" { 53 + seriesURL = forgejoWebURL 54 + } 55 + 47 56 if pair.Sync.Git || pair.Sync.Patches { 48 57 rs.git = &GitSyncer{ 49 58 RepoPair: pair.Name, 50 59 WorkDir: workDir, 51 60 ForgejoURL: authenticatedCloneURL(repo.CloneURL, token), 52 61 ForgejoWebURL: forgejoWebURL, 62 + RadicleWebURL: radicleWebURL, 53 63 ForgejoBranch: repo.DefaultBranch, 54 64 RID: pair.Radicle.RID, 55 65 RadHome: pair.Radicle.RadHome, 56 66 State: st, 67 + Series: series, 68 + SeriesURL: seriesURL, 57 69 } 58 70 } 59 71 if pair.Sync.Issues { 60 72 rs.issues = &IssueSyncer{ 61 73 RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st, 62 74 ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL, 75 + Series: series, SeriesURL: seriesURL, 63 76 } 64 77 } 65 78 if pair.Sync.Patches { ··· 73 86 State: st, 74 87 ForgejoWebURL: forgejoWebURL, 75 88 RadicleWebURL: radicleWebURL, 89 + Series: series, 90 + SeriesURL: seriesURL, 76 91 } 77 92 } 78 93 return rs, nil ··· 94 109 95 110 // Name is this pair's name, as given in the config. 96 111 func (rs *RepoSyncer) Name() string { return rs.pair.Name } 112 + 113 + // Series is the dashboard row this pair's activity groups under: its 114 + // configured series, or its own name if unset. 115 + func (rs *RepoSyncer) Series() string { 116 + if rs.pair.Series != "" { 117 + return rs.pair.Series 118 + } 119 + return rs.pair.Name 120 + } 97 121 98 122 // Run executes every enabled scope once, logging and continuing past 99 123 // per-scope errors so one broken scope doesn't block the others. It