Federated via graft
0

Configure Feed

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

Redesign status dashboard: light theme, clickable events, footer

- Always light (Atlassian-style), no more automatic dark mode
- Each calendar tooltip entry and each recent commit links directly to
the forge issue/PR/commit or the Radicle explorer page it produced
- Fixed the tooltip being clipped by the calendar's horizontal scroll
container (overflow-y: visible instead of the inherited overflow: auto)
- Footer with the source repository link and license
- activity_log now stores a URL per entry; new optional config fields
source_url (footer link) and radicle.explorer_url (Radicle links)

+260 -133
+1 -1
cmd/sync/main.go
··· 52 52 syncers = append(syncers, rs) 53 53 } 54 54 55 - tracker := status.NewTracker(st) 55 + tracker := status.NewTracker(st, cfg.SourceURL) 56 56 runAll := func() { 57 57 for _, rs := range syncers { 58 58 gitErr, issuesErr, patchErr := rs.Run(log)
+6
config.example.yaml
··· 5 5 sync_interval: 5m 6 6 state_db: /var/lib/graft/state.db 7 7 8 + # Shown in the status dashboard's footer. Optional. 9 + source_url: https://git.example.org/alice/graft 10 + 8 11 repos: 9 12 - name: testrepo 10 13 forgejo: ··· 16 19 rid: rad:z2YWuPL53okTkr5mgb7WCKsfxG7h8 17 20 http_base_url: https://r1.cyberwild.org 18 21 rad_home: /home/sync/.radicle 22 + # A Radicle Explorer deployment, for "view this on Radicle" links in 23 + # the status dashboard. Optional: links are omitted if unset. 24 + explorer_url: https://app.radicle.xyz 19 25 sync: 20 26 git: true 21 27 issues: true
+8
internal/config/config.go
··· 14 14 SyncInterval time.Duration `yaml:"sync_interval"` 15 15 StateDB string `yaml:"state_db"` 16 16 Repos []RepoPair `yaml:"repos"` 17 + // SourceURL is graft's own source repository, shown in the status 18 + // dashboard's footer. Optional. 19 + SourceURL string `yaml:"source_url"` 17 20 } 18 21 19 22 // RepoPair links one Forgejo repository to one Radicle repository and ··· 44 47 RID string `yaml:"rid"` 45 48 HTTPBaseURL string `yaml:"http_base_url"` 46 49 RadHome string `yaml:"rad_home"` 50 + // ExplorerURL is a Radicle Explorer deployment (e.g. 51 + // https://app.radicle.xyz, or a self-hosted one) used to build "view 52 + // this issue/patch" links in the status dashboard. Optional: links are 53 + // omitted if unset. 54 + ExplorerURL string `yaml:"explorer_url"` 47 55 } 48 56 49 57 // SyncScope selects which content types are synchronized for a repo pair.
+22 -8
internal/state/store.go
··· 6 6 import ( 7 7 "database/sql" 8 8 "fmt" 9 + "strings" 9 10 "time" 10 11 11 12 _ "modernc.org/sqlite" ··· 66 67 kind TEXT NOT NULL, -- 'git', 'issue', 'patch' 67 68 direction TEXT NOT NULL, -- 'forgejo_to_radicle' or 'radicle_to_forgejo' 68 69 summary TEXT NOT NULL, 70 + url TEXT NOT NULL DEFAULT '', 69 71 occurred_at TEXT NOT NULL 70 72 ); 71 73 72 74 CREATE INDEX IF NOT EXISTS idx_activity_log_occurred_at 73 75 ON activity_log (occurred_at); 74 76 `) 75 - return err 77 + if err != nil { 78 + return err 79 + } 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 85 + } 86 + return nil 76 87 } 77 88 78 89 // GitCursor returns the last-synced commit SHAs on each side for a repo pair. ··· 152 163 ) 153 164 154 165 // 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 { 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 { 157 170 _, 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)) 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)) 161 174 return err 162 175 } 163 176 ··· 167 180 Kind string 168 181 Direction string 169 182 Summary string 183 + URL string 170 184 OccurredAt time.Time 171 185 } 172 186 173 187 // ActivitySince returns every activity entry at or after since, newest first. 174 188 func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) { 175 189 rows, err := s.db.Query(` 176 - SELECT repo_pair, kind, direction, summary, occurred_at FROM activity_log 190 + SELECT repo_pair, kind, direction, summary, url, occurred_at FROM activity_log 177 191 WHERE occurred_at >= ? 178 192 ORDER BY occurred_at DESC 179 193 `, since.UTC().Format(time.RFC3339)) ··· 186 200 for rows.Next() { 187 201 var e ActivityEntry 188 202 var occurredAt string 189 - if err := rows.Scan(&e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &occurredAt); err != nil { 203 + if err := rows.Scan(&e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &occurredAt); err != nil { 190 204 return nil, err 191 205 } 192 206 e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt)
+171 -112
internal/status/status.go
··· 6 6 7 7 import ( 8 8 "encoding/json" 9 - "fmt" 10 9 "html/template" 11 10 "net/http" 12 11 "sort" 13 - "strings" 14 12 "sync" 15 13 "time" 16 14 ··· 36 34 type Tracker struct { 37 35 startedAt time.Time 38 36 store *state.Store 37 + sourceURL string 39 38 40 39 mu sync.Mutex 41 40 pairs map[string]PairResult 42 41 } 43 42 44 - func NewTracker(store *state.Store) *Tracker { 45 - return &Tracker{startedAt: time.Now(), store: store, pairs: map[string]PairResult{}} 43 + func NewTracker(store *state.Store, sourceURL string) *Tracker { 44 + return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} 46 45 } 47 46 48 47 // Record stores the outcome of a pass for one pair. Pass nil for a scope ··· 81 80 82 81 // Handler serves: 83 82 // 84 - // GET /healthz - 200 if every pair's last pass was clean, 503 otherwise 83 + // GET /healthz - 200 if every pair's last pass was clean, 503 otherwise 85 84 // GET /api/status - the JSON snapshot Record() has been fed 86 - // GET / - the activity dashboard (calendar + recent commits) 85 + // GET / - the activity dashboard (calendar + recent commits) 87 86 func (t *Tracker) Handler() http.Handler { 88 87 mux := http.NewServeMux() 89 88 ··· 119 118 } 120 119 121 120 data := dashboardData{ 122 - Pairs: t.snapshot(), 123 - Weeks: buildCalendar(entries, since), 124 - Commits: recentCommits(entries), 121 + Pairs: t.snapshot(), 122 + Weeks: buildCalendar(entries, since), 123 + Commits: recentCommits(entries), 124 + SourceURL: t.sourceURL, 125 125 } 126 126 w.Header().Set("Content-Type", "text/html; charset=utf-8") 127 127 if err := dashboardTmpl.Execute(w, data); err != nil { ··· 130 130 } 131 131 132 132 type dashboardData struct { 133 - Pairs []PairResult 134 - Weeks [][]day 135 - Commits []commitLine 133 + Pairs []PairResult 134 + Weeks [][]day 135 + Commits []commitLine 136 + SourceURL string 136 137 } 137 138 138 139 type day struct { 139 - Date string // YYYY-MM-DD 140 + Date string // YYYY-MM-DD, human-readable 140 141 Count int 141 142 Level int // 0-4, for the color ramp 142 - Tooltip string 143 + Events []event 143 144 InRange bool 145 + } 146 + 147 + // event is one activity_log row, shaped for the tooltip's clickable list. 148 + type event struct { 149 + Label string // "3 commits", "1 issue", etc. joined for the summary line 150 + Text string 151 + URL string 144 152 } 145 153 146 154 type commitLine struct { 147 155 When string 148 156 Pair string 149 157 Message string 158 + URL string 150 159 } 151 160 152 161 // buildCalendar buckets entries by day and lays them out GitHub-style: ··· 170 179 key := d.Format("2006-01-02") 171 180 es := byDay[key] 172 181 week = append(week, day{ 173 - Date: key, 182 + Date: d.Format("Jan 2, 2006"), 174 183 Count: len(es), 175 184 Level: levelFor(len(es)), 176 - Tooltip: tooltipFor(key, es), 185 + Events: toEvents(es), 177 186 InRange: !d.Before(since), 178 187 }) 179 188 if d.Weekday() == time.Saturday { ··· 202 211 } 203 212 } 204 213 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{} 214 + func toEvents(es []state.ActivityEntry) []event { 215 + out := make([]event, 0, len(es)) 210 216 for _, e := range es { 211 - byKind[e.Kind]++ 217 + out = append(out, event{ 218 + Label: kindLabel(e.Kind), 219 + Text: e.Summary, 220 + URL: e.URL, 221 + }) 212 222 } 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, ", ")) 223 + return out 220 224 } 221 225 222 - func plural(word string, n int) string { 223 - if n == 1 { 224 - return word 225 - } 226 - if word == "patch" { 227 - return "patches" 226 + func kindLabel(kind string) string { 227 + switch kind { 228 + case "git": 229 + return "Commit" 230 + case "issue": 231 + return "Issue" 232 + case "patch": 233 + return "Patch" 234 + default: 235 + return kind 228 236 } 229 - return word + "s" 230 237 } 231 238 232 239 func recentCommits(entries []state.ActivityEntry) []commitLine { ··· 236 243 continue 237 244 } 238 245 out = append(out, commitLine{ 239 - When: e.OccurredAt.Format("2006-01-02 15:04"), 246 + When: e.OccurredAt.Format("Jan 2, 15:04"), 240 247 Pair: e.RepoPair, 241 248 Message: e.Summary, 249 + URL: e.URL, 242 250 }) 243 251 } 244 252 return out ··· 248 256 <html lang="en"> 249 257 <head> 250 258 <meta charset="utf-8"> 259 + <meta name="viewport" content="width=device-width, initial-scale=1"> 251 260 <title>graft</title> 252 261 <style> 253 262 :root { 254 263 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 - } 264 + --surface: #ffffff; 265 + --surface-sunk: #f4f5f7; 266 + --text: #172b4d; 267 + --text-muted: #6b778c; 268 + --border: #dfe1e6; 269 + --brand: #0052cc; 270 + --brand-dark: #0747a6; 271 + --cell-0: #f4f5f7; 272 + --cell-1: #cde2fb; 273 + --cell-2: #86b6ef; 274 + --cell-3: #3987e5; 275 + --cell-4: #184f95; 276 + --bad: #de350b; 277 + --good: #36b37e; 280 278 } 281 279 * { box-sizing: border-box; } 282 280 body { 283 281 margin: 0; 284 - padding: 2.5rem 1.5rem; 282 + background: var(--surface-sunk); 283 + color: var(--text); 284 + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 285 + } 286 + main { max-width: 920px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; } 287 + 288 + .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 289 + h1 { font-size: 1.25rem; margin: 0; font-weight: 600; } 290 + .sub { color: var(--text-muted); font-size: .85rem; } 291 + 292 + .card { 285 293 background: var(--surface); 286 - color: var(--text); 287 - font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 294 + border: 1px solid var(--border); 295 + border-radius: 4px; 296 + padding: 1.25rem 1.5rem; 297 + margin-bottom: 1.25rem; 288 298 } 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 299 293 - .pairs { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 2rem; } 300 + .pairs { display: flex; flex-wrap: wrap; gap: .5rem; } 294 301 .pair { 295 - border: 1px solid var(--border); border-radius: 6px; 296 - padding: .4rem .7rem; font-size: .8rem; display: flex; align-items: center; gap: .4rem; 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; 297 304 } 298 - .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cell-3); flex: none; } 305 + .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); flex: none; } 299 306 .dot.bad { background: var(--bad); } 300 307 301 - .calendar { display: flex; gap: 3px; margin-bottom: 2rem; overflow-x: auto; padding-bottom: .5rem; } 308 + .calendar-scroll { overflow-x: auto; overflow-y: visible; padding: 20px 0 4px; } 309 + .calendar { display: flex; gap: 3px; width: max-content; } 302 310 .week { display: flex; flex-direction: column; gap: 3px; } 303 311 .cell { 304 - width: 11px; height: 11px; border-radius: 2px; 312 + width: 12px; height: 12px; border-radius: 2px; 305 313 background: var(--cell-0); position: relative; cursor: default; 306 314 } 307 315 .cell[data-level="1"] { background: var(--cell-1); } ··· 310 318 .cell[data-level="4"] { background: var(--cell-4); } 311 319 .cell[data-out-of-range="1"] { visibility: hidden; } 312 320 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; 321 + .tip { 322 + display: none; position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); 323 + background: var(--text); color: #fff; border-radius: 6px; 324 + font-size: .78rem; white-space: nowrap; z-index: 10; padding: .5rem 0; 325 + box-shadow: 0 4px 12px rgba(9,30,66,.25); 317 326 } 318 - .cell:hover .tip { display: block; } 327 + .cell:hover .tip, .tip:hover { display: block; } 328 + .tip .tip-date { padding: 0 .75rem .35rem; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.15); margin-bottom: .35rem; } 329 + .tip a.tip-row { 330 + display: block; padding: .3rem .75rem; color: #fff; text-decoration: none; white-space: nowrap; 331 + } 332 + .tip a.tip-row:hover { background: rgba(255,255,255,.12); } 333 + .tip .tip-kind { color: #b3bac5; margin-right: .4em; } 334 + .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; } 319 335 320 - h2 { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .75rem; } 336 + h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; } 321 337 .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; } 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; 341 + } 342 + a.commit:hover .msg { color: var(--brand); text-decoration: underline; } 343 + .commit .when { color: var(--text-muted); flex: none; width: 9em; } 324 344 .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; } 345 + .commit .msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; } 346 + .empty { color: var(--text-muted); padding: .5rem 0; } 347 + 348 + footer { 349 + max-width: 920px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; 350 + color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem; 351 + } 352 + footer a { color: var(--text-muted); } 327 353 </style> 328 354 </head> 329 355 <body> 330 356 <main> 331 - <h1>graft</h1> 332 - <p class="sub">Forgejo &lt;-&gt; Radicle sync — last {{len .Weeks}} weeks</p> 357 + <div class="top"> 358 + <h1>graft</h1> 359 + <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 90 days</span> 360 + </div> 333 361 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}} 362 + <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}} 368 + </div> 369 + {{else}} 370 + <span class="empty">no pairs configured</span> 371 + {{end}} 339 372 </div> 340 - {{else}} 341 - <span class="empty">no pairs configured</span> 342 - {{end}} 343 373 </div> 344 374 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> 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}} 393 + </div> 394 + </div> 395 + {{end}} 396 + </div> 397 + {{end}} 351 398 </div> 352 - {{end}} 353 399 </div> 354 - {{end}} 355 400 </div> 356 401 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> 402 + <div class="card"> 403 + <h2>Recent commits</h2> 404 + <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}} 413 + <div class="commit"> 414 + <span class="when">{{.When}}</span> 415 + <span class="pair">{{.Pair}}</span> 416 + <span class="msg">{{.Message}}</span> 417 + </div> 418 + {{end}} 419 + {{else}} 420 + <p class="empty">nothing mirrored yet</p> 421 + {{end}} 364 422 </div> 365 - {{else}} 366 - <p class="empty">nothing mirrored yet</p> 367 - {{end}} 368 423 </div> 369 424 </main> 425 + <footer> 426 + {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}} 427 + <span>GPLv3</span> 428 + </footer> 370 429 </body> 371 430 </html> 372 431 `))
+15 -3
internal/sync/git.go
··· 25 25 type GitSyncer struct { 26 26 RepoPair string // key used in the state store 27 27 WorkDir string // local clone, created on first run if absent 28 - ForgejoURL string // authenticated clone URL (token embedded) 28 + ForgejoURL string // authenticated clone URL (token embedded), for git 29 + ForgejoWebURL string // https://host/owner/repo, for links in the UI 29 30 ForgejoBranch string 30 31 RID string 31 32 RadHome string ··· 212 213 if oldHead != "" { 213 214 rangeSpec = oldHead + "..FETCH_HEAD" 214 215 } 215 - out, err := g.run("log", "--pretty=format:%h %s", rangeSpec) 216 + // %x1f (unit separator) between fields: commit subjects can contain 217 + // anything else printable, so a space or comma delimiter would be 218 + // ambiguous to split back apart. 219 + out, err := g.run("log", "--pretty=format:%H%x1f%h %s", rangeSpec) 216 220 if err != nil || out == "" { 217 221 return 218 222 } ··· 220 224 if line == "" { 221 225 continue 222 226 } 223 - g.State.LogActivity(g.RepoPair, "git", direction, line) 227 + full, summary, ok := strings.Cut(line, "\x1f") 228 + if !ok { 229 + continue 230 + } 231 + url := "" 232 + if g.ForgejoWebURL != "" { 233 + url = g.ForgejoWebURL + "/commit/" + full 234 + } 235 + g.State.LogActivity(g.RepoPair, "git", direction, summary, url) 224 236 } 225 237 }
+8 -6
internal/sync/issues.go
··· 17 17 // IssueSyncer mirrors issues between one Forgejo repository and one Radicle 18 18 // repository, create-only (see package doc for what that means). 19 19 type IssueSyncer struct { 20 - RepoPair string 21 - Forgejo *forgejo.Client 22 - Radicle *radicle.Client 23 - State *state.Store 20 + RepoPair string 21 + Forgejo *forgejo.Client 22 + Radicle *radicle.Client 23 + State *state.Store 24 + ForgejoWebURL string // https://host/owner/repo, for links in the UI 25 + RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 24 26 } 25 27 26 28 func (s *IssueSyncer) Sync() error { ··· 68 70 }); err != nil { 69 71 return err 70 72 } 71 - s.State.LogActivity(s.RepoPair, "issue", state.ForgejoToRadicle, fi.Title) 73 + s.State.LogActivity(s.RepoPair, "issue", state.ForgejoToRadicle, fi.Title, s.RadicleWebURL+"/issues/"+radicleID) 72 74 return nil 73 75 } 74 76 ··· 94 96 }); err != nil { 95 97 return err 96 98 } 97 - s.State.LogActivity(s.RepoPair, "issue", state.RadicleToForgejo, ri.Title) 99 + s.State.LogActivity(s.RepoPair, "issue", state.RadicleToForgejo, ri.Title, fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, fi.Index)) 98 100 return nil 99 101 } 100 102
+4 -2
internal/sync/patches.go
··· 24 24 Forgejo *forgejo.Client 25 25 Radicle *radicle.Client 26 26 State *state.Store 27 + ForgejoWebURL string // https://host/owner/repo, for links in the UI 28 + RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 27 29 } 28 30 29 31 func (s *PatchSyncer) git(args ...string) (string, error) { ··· 114 116 }); err != nil { 115 117 return err 116 118 } 117 - s.State.LogActivity(s.RepoPair, "patch", state.ForgejoToRadicle, pr.Title) 119 + s.State.LogActivity(s.RepoPair, "patch", state.ForgejoToRadicle, pr.Title, s.RadicleWebURL+"/patches/"+m[1]) 118 120 return nil 119 121 } 120 122 ··· 155 157 }); err != nil { 156 158 return err 157 159 } 158 - s.State.LogActivity(s.RepoPair, "patch", state.RadicleToForgejo, p.Title) 160 + s.State.LogActivity(s.RepoPair, "patch", state.RadicleToForgejo, p.Title, fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index)) 159 161 return nil 160 162 } 161 163
+25 -1
internal/sync/syncer.go
··· 5 5 "log/slog" 6 6 "net/url" 7 7 "path/filepath" 8 + "strings" 8 9 9 10 "graft/internal/config" 10 11 "graft/internal/forgejo" ··· 40 41 41 42 rs := &RepoSyncer{pair: pair, forgejo: fc, radicle: rc} 42 43 44 + forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 45 + radicleWebURL := radicleExplorerLink(pair.Radicle) 46 + 43 47 if pair.Sync.Git || pair.Sync.Patches { 44 48 rs.git = &GitSyncer{ 45 49 RepoPair: pair.Name, 46 50 WorkDir: workDir, 47 51 ForgejoURL: authenticatedCloneURL(repo.CloneURL, token), 52 + ForgejoWebURL: forgejoWebURL, 48 53 ForgejoBranch: repo.DefaultBranch, 49 54 RID: pair.Radicle.RID, 50 55 RadHome: pair.Radicle.RadHome, ··· 52 57 } 53 58 } 54 59 if pair.Sync.Issues { 55 - rs.issues = &IssueSyncer{RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st} 60 + rs.issues = &IssueSyncer{ 61 + RepoPair: pair.Name, Forgejo: fc, Radicle: rc, State: st, 62 + ForgejoWebURL: forgejoWebURL, RadicleWebURL: radicleWebURL, 63 + } 56 64 } 57 65 if pair.Sync.Patches { 58 66 rs.patches = &PatchSyncer{ ··· 63 71 Forgejo: fc, 64 72 Radicle: rc, 65 73 State: st, 74 + ForgejoWebURL: forgejoWebURL, 75 + RadicleWebURL: radicleWebURL, 66 76 } 67 77 } 68 78 return rs, nil 79 + } 80 + 81 + // radicleExplorerLink builds the base URL for "view this on Radicle" links: 82 + // <explorer>/nodes/<httpd-host>/<rid>. Empty if no explorer_url is 83 + // configured, so templates can skip the link entirely. 84 + func radicleExplorerLink(rt config.RadicleTarget) string { 85 + if rt.ExplorerURL == "" { 86 + return "" 87 + } 88 + host := rt.HTTPBaseURL 89 + if u, err := url.Parse(rt.HTTPBaseURL); err == nil && u.Host != "" { 90 + host = u.Host 91 + } 92 + return strings.TrimRight(rt.ExplorerURL, "/") + "/nodes/" + host + "/" + rt.RID 69 93 } 70 94 71 95 // Name is this pair's name, as given in the config.