Federated via graft
0

Configure Feed

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

Align dashboard cells across healthy mirrors, fix mobile row layout, harden comment mirror-loop guard

- Dashboard: a mirror with no live sync error now renders the series' full
distinct-event set (real cells where it logged the push itself, synthetic
elsewhere), so mirrors genuinely holding the same content look aligned
instead of one appearing behind due to which host happened to get logged.
A mirror with a real, current sync error skips that fill and shows only
what it has confirmed, plus a small red badge (tooltip carries the error).
- admin: .row (paired form fields) now wraps on narrow viewports instead of
overflowing horizontally — fixes New repo's layout on mobile.
- Comments: the mirror-loop guard now checks an invisible marker
(state.MarkMirrored/IsMirroredComment) instead of matching the visible
"**via Forgejo:**" etc. prefix text, so a real comment that happens to
start with the same phrase is never mistaken for graft's own mirror.

+303 -75
+1 -1
cmd/sync/bluesky_replies.go
··· 101 101 continue 102 102 } 103 103 104 - body := fmt.Sprintf("**via Bluesky, @%s:**\n\n%s", n.Author.Handle, strings.TrimSpace(n.Record.Text)) 104 + body := state.MarkMirrored(fmt.Sprintf("**via Bluesky, @%s:**\n\n%s", n.Author.Handle, strings.TrimSpace(n.Record.Text))) 105 105 if err := rs.CommentOnItem(e.Kind, e.ForgejoID, e.RadicleID, body); err != nil { 106 106 log.Error("bluesky reply: post comment", "series", series, "repo_pair", e.RepoPair, "err", err) 107 107 continue
+4 -3
cmd/sync/main.go
··· 414 414 func buildTopology(pairs []config.RepoPair, aiTargetHosts map[string]map[string]bool) map[string][]status.ServerRef { 415 415 topology := map[string][]status.ServerRef{} 416 416 seen := map[string]bool{} 417 - add := func(series, host, repoURL string, radicle, ai bool) { 417 + add := func(series, host, repoURL, pairName string, radicle, ai bool) { 418 418 if host == "" { 419 419 return 420 420 } ··· 428 428 URL: repoURL, 429 429 Radicle: radicle, 430 430 AuthorizedIntegration: ai, 431 + PairName: pairName, 431 432 }) 432 433 } 433 434 ··· 442 443 fHost = u.Host 443 444 } 444 445 fURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 445 - add(series, fHost, fURL, false, aiTargetHosts[series][fHost]) 446 + add(series, fHost, fURL, pair.Name, false, aiTargetHosts[series][fHost]) 446 447 447 448 rHost := "" 448 449 if u, err := url.Parse(pair.Radicle.HTTPBaseURL); err == nil { 449 450 rHost = u.Host 450 451 } 451 - add(series, rHost, gsync.RadicleExplorerLink(pair.Radicle), true, false) 452 + add(series, rHost, gsync.RadicleExplorerLink(pair.Radicle), pair.Name, true, false) 452 453 } 453 454 return topology 454 455 }
+1 -1
internal/activitypub/handlers.go
··· 312 312 if who == "" { 313 313 who = remoteActor.Name 314 314 } 315 - body := fmt.Sprintf("**via Fediverse, @%s:**\n\n%s", who, stripHTML(note.Content)) 315 + body := state.MarkMirrored(fmt.Sprintf("**via Fediverse, @%s:**\n\n%s", who, stripHTML(note.Content))) 316 316 317 317 if err := h.postComment(e.RepoPair, e.Kind, e.ForgejoID, e.RadicleID, body); err != nil { 318 318 h.log.Error("ap reply: post comment", "series", series, "repo_pair", e.RepoPair, "err", err)
+2 -2
internal/admin/admin.go
··· 216 216 } 217 217 textarea { resize: vertical; min-height: 4.5rem; font-family: ui-monospace, monospace; font-size: .82rem; } 218 218 .hint { font-size: .74rem; color: var(--text-muted); margin-top: .3rem; } 219 - .row { display: flex; gap: 1rem; } 220 - .row > div { flex: 1; } 219 + .row { display: flex; flex-wrap: wrap; gap: 1rem; } 220 + .row > div { flex: 1 1 12rem; min-width: 0; } 221 221 .checks { display: flex; gap: 1.25rem; margin-top: .5rem; } 222 222 .checks label { display: flex; align-items: center; gap: .4rem; font-weight: 500; margin: 0; } 223 223 .checks input { width: auto; }
+29
internal/state/mirror.go
··· 1 + package state 2 + 3 + import "strings" 4 + 5 + // mirrorMarker tags a comment body as graft's own mirrored output, using 6 + // zero-width characters a person can't type by accident — or, short of 7 + // deliberately pasting raw Unicode escapes into a comment box, at all. 8 + // The earlier guard matched on the visible "**via Forgejo:**" etc. prefix 9 + // text itself, which meant a real user typing that same phrase at the 10 + // start of their own comment got it silently excluded from mirroring. 11 + // Low severity (it only suppresses mirroring their own comment, nothing 12 + // escalates), but easy to close: the visible attribution text stays for 13 + // humans reading it, this invisible marker is what the guard actually 14 + // checks. 15 + const mirrorMarker = "​​graft:mirrored​" 16 + 17 + // MarkMirrored appends the invisible marker to a comment body that's 18 + // about to be posted as a mirror of content from elsewhere. 19 + func MarkMirrored(body string) string { 20 + return body + mirrorMarker 21 + } 22 + 23 + // IsMirroredComment reports whether body carries graft's own mirror 24 + // marker — i.e. whether a comment-sync pass produced it, directly or via 25 + // the ActivityPub/AT Proto reply bridges, and so must never be mirrored 26 + // back out. 27 + func IsMirroredComment(body string) bool { 28 + return strings.Contains(body, mirrorMarker) 29 + }
+15
internal/state/mirror_test.go
··· 1 + package state 2 + 3 + import "testing" 4 + 5 + func TestMirrorMarkerNotSpoofableByVisibleText(t *testing.T) { 6 + real := "**via Forgejo:** I'm just quoting the mirror format, not a real mirrored comment" 7 + if IsMirroredComment(real) { 8 + t.Fatal("a genuine comment that merely starts with the old visible prefix must not be treated as mirrored") 9 + } 10 + 11 + marked := MarkMirrored("**via Forgejo:**\n\nsome mirrored body") 12 + if !IsMirroredComment(marked) { 13 + t.Fatal("a body carrying the invisible marker must be recognized as mirrored") 14 + } 15 + }
+125 -42
internal/status/status.go
··· 32 32 return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" 33 33 } 34 34 35 + // errText joins whichever scopes actually failed on this pair's last pass 36 + // into one tooltip-sized message. Empty if the pass was clean. 37 + func (r PairResult) errText() string { 38 + var parts []string 39 + if r.GitError != "" { 40 + parts = append(parts, "git: "+r.GitError) 41 + } 42 + if r.IssuesError != "" { 43 + parts = append(parts, "issues: "+r.IssuesError) 44 + } 45 + if r.PatchError != "" { 46 + parts = append(parts, "patches: "+r.PatchError) 47 + } 48 + return strings.Join(parts, " | ") 49 + } 50 + 35 51 // ServerRef is one repo pair's declared mirror target — a specific Forgejo 36 52 // instance or a specific Radicle node — as configured, regardless of 37 53 // whether it has ever actually produced an event yet. ··· 40 56 URL string // repo root on that host 41 57 Radicle bool // true if this side is a Radicle node 42 58 AuthorizedIntegration bool // true if this Forgejo side receives pushes directly from another pair's Forgejo, bypassing graft 59 + PairName string // the config.RepoPair.Name this side belongs to — links a sub-row back to its PairResult for error surfacing 43 60 } 44 61 45 62 // Tracker holds the latest result per repo pair, safe for concurrent use, ··· 162 179 return 163 180 } 164 181 165 - pairs := groupPairStatus(t.snapshot()) 182 + results := t.snapshot() 183 + pairs := groupPairStatus(results) 166 184 health := make(map[string]bool, len(pairs)) 167 185 for _, p := range pairs { 168 186 health[p.Name] = p.OK 169 187 } 188 + pairResults := make(map[string]PairResult, len(results)) 189 + for _, r := range results { 190 + pairResults[r.Name] = r 191 + } 170 192 171 193 t.mu.Lock() 172 194 topology := t.topology ··· 174 196 t.mu.Unlock() 175 197 176 198 data := dashboardData{ 177 - Series: buildSeriesRows(entries, health, topology), 199 + Series: buildSeriesRows(entries, health, topology, pairResults), 178 200 Activity: recentActivity(entries), 179 201 SourceURL: t.sourceURL, 180 202 SocialEnabled: publicHost != "", ··· 310 332 Label string 311 333 URL string 312 334 Events []event 335 + // Error is set when this side's most recent sync pass actually 336 + // failed — surfaced as a visible badge instead of a silently 337 + // shorter row. A row with an Error never gets the synthetic 338 + // alignment fill below: what you see is only what's confirmed. 339 + Error string 313 340 } 314 341 315 342 // event is one heatmap cell, one tooltip, one link — either a real ··· 410 437 // Statuspage style: one full-width horizontal strip per series. Each entry 411 438 // becomes its own cell, oldest first, so a busy day never hides events 412 439 // behind one shared tooltip. 413 - func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool, topology map[string][]ServerRef) []seriesRow { 440 + func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool, topology map[string][]ServerRef, pairResults map[string]PairResult) []seriesRow { 414 441 type subAcc struct { 415 442 url string 416 443 events []state.ActivityEntry 417 444 radicle bool 418 445 ai bool 446 + // err is this side's live sync-error message (empty if its last 447 + // pass was clean). Set from pairResults via the topology's 448 + // PairName, so it reflects reality even before this side has 449 + // ever logged an event of its own. 450 + err string 451 + // byKey indexes events by dedupKey — built once per side right 452 + // before rendering, see buildSeriesRows. 453 + byKey map[string]state.ActivityEntry 419 454 } 420 455 type seriesAcc struct { 421 456 subOrder []string ··· 455 490 } 456 491 sub.radicle = ref.Radicle 457 492 sub.ai = ref.AuthorizedIntegration 493 + if pr, ok := pairResults[ref.PairName]; ok { 494 + sub.err = pr.errText() 495 + } 458 496 } 459 497 } 460 498 ··· 520 558 return reps[repKeys[i]].entry.OccurredAt.Before(reps[repKeys[j]].entry.OccurredAt) 521 559 }) 522 560 561 + // byKey lets the render loop below ask "did this specific side log 562 + // this specific event itself?" in O(1), so a real cell is used 563 + // wherever one exists and a synthetic one only fills the gaps — 564 + // never the other way around. 565 + for _, sub := range sa.subs { 566 + sub.byKey = make(map[string]state.ActivityEntry, len(sub.events)) 567 + for _, e := range sub.events { 568 + sub.byKey[dedupKey(e.Kind, e.URL, e.Summary)] = e 569 + } 570 + } 571 + 523 572 subRows := make([]subRow, 0, len(subOrder)) 524 573 for _, label := range subOrder { 525 574 sub := sa.subs[label] 526 - if len(sub.events) == 0 && len(reps) > 0 { 527 - cellState := "source" 528 - if sub.ai || sub.radicle { 529 - cellState = "replicated" 530 - } 531 - synth := make([]event, 0, len(repKeys)) 532 - for _, k := range repKeys { 533 - r := reps[k] 534 - synth = append(synth, event{ 535 - Kind: r.entry.Kind, 536 - Label: kindLabel(r.entry.Kind), 537 - Text: r.entry.Summary, 538 - URL: reconstructLink(sub.url, r.entry.Kind, r.entry.URL), 539 - Server: label, 540 - When: r.entry.OccurredAt.Format("Mon, Jan 2, 15:04"), 541 - State: cellState, 542 - OriginLabel: r.label, 543 - }) 544 - } 545 - subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: synth}) 575 + 576 + if sub.err != "" { 577 + // A side with a live sync error doesn't get the alignment 578 + // fill below — showing it as "caught up" would hide the 579 + // exact problem this dashboard exists to surface. What's 580 + // rendered is only what this side has actually confirmed 581 + // itself, so a real gap stays visible, backed by the 582 + // error badge the template renders from subRow.Error. 583 + es := make([]state.ActivityEntry, len(sub.events)) 584 + copy(es, sub.events) 585 + sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) 586 + subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es), Error: sub.err}) 587 + continue 588 + } 589 + 590 + if len(reps) == 0 { 591 + subRows = append(subRows, subRow{Label: label, URL: sub.url}) 546 592 continue 547 593 } 548 594 549 - // entries arrive newest-first (see ActivitySince); the 550 - // heatmap reads left-to-right as oldest-to-newest. 551 - es := make([]state.ActivityEntry, len(sub.events)) 552 - copy(es, sub.events) 553 - sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) 554 - subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es)}) 595 + // A healthy side (no live error) always renders the full, 596 + // series-wide set of distinct events in the same order, so 597 + // two sides genuinely holding the same content always look 598 + // identical — real cells wherever this side logged the event 599 + // itself, synthetic ones filling in the rest (received via 600 + // Radicle gossip, an Authorized Integration, or simply not 601 + // the side that happened to get logged for that push). 602 + cellState := "source" 603 + if sub.ai || sub.radicle { 604 + cellState = "replicated" 605 + } 606 + cells := make([]event, 0, len(repKeys)) 607 + for _, k := range repKeys { 608 + if real, ok := sub.byKey[k]; ok { 609 + cells = append(cells, toEvent(real)) 610 + continue 611 + } 612 + r := reps[k] 613 + cells = append(cells, event{ 614 + Kind: r.entry.Kind, 615 + Label: kindLabel(r.entry.Kind), 616 + Text: r.entry.Summary, 617 + URL: reconstructLink(sub.url, r.entry.Kind, r.entry.URL), 618 + Server: label, 619 + When: r.entry.OccurredAt.Format("Mon, Jan 2, 15:04"), 620 + State: cellState, 621 + OriginLabel: r.label, 622 + }) 623 + } 624 + subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: cells}) 555 625 } 556 626 557 627 ok, known := health[name] ··· 563 633 return rows 564 634 } 565 635 636 + func toEvent(e state.ActivityEntry) event { 637 + server := hostLabel(e.URL) 638 + if server == "" { 639 + server = serverLabel(e.RepoPair, e.Series) 640 + } 641 + return event{ 642 + Kind: e.Kind, 643 + Label: kindLabel(e.Kind), 644 + Text: e.Summary, 645 + URL: e.URL, 646 + Server: server, 647 + When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), 648 + Origin: originLabel(e.Origin), 649 + } 650 + } 651 + 566 652 func toEvents(es []state.ActivityEntry) []event { 567 653 out := make([]event, 0, len(es)) 568 654 for _, e := range es { 569 - server := hostLabel(e.URL) 570 - if server == "" { 571 - server = serverLabel(e.RepoPair, e.Series) 572 - } 573 - out = append(out, event{ 574 - Kind: e.Kind, 575 - Label: kindLabel(e.Kind), 576 - Text: e.Summary, 577 - URL: e.URL, 578 - Server: server, 579 - When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), 580 - Origin: originLabel(e.Origin), 581 - }) 655 + out = append(out, toEvent(e)) 582 656 } 583 657 return out 584 658 } ··· 789 863 } 790 864 a.subrow-name { color: var(--brand); } 791 865 a.subrow-name:hover { text-decoration: underline; } 866 + .subrow-err { 867 + flex: none; width: 10px; height: 10px; border-radius: 3px; 868 + background: var(--bad); cursor: default; position: relative; 869 + } 870 + .subrow-err .tip { min-width: 16rem; white-space: normal; } 871 + .subrow-err:hover .tip { display: block; } 792 872 .days { display: flex; gap: 3px; flex-wrap: wrap; } 793 873 .cell { 794 874 flex: none; width: 10px; height: 22px; border-radius: 3px; ··· 887 967 <div class="subrows"> 888 968 {{range .SubRows}} 889 969 <div class="subrow"> 970 + {{if .Error}} 971 + <span class="subrow-err"><div class="tip"><div class="tip-date">last pass failed</div><div class="tip-note">{{.Error}}</div></div></span> 972 + {{end}} 890 973 {{if .URL}} 891 974 <a class="subrow-name" href="{{.URL}}" target="_blank" rel="noopener">{{.Label}}</a> 892 975 {{else}}
+118
internal/status/status_test.go
··· 1 + package status 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + 7 + "graft/internal/state" 8 + ) 9 + 10 + func mkEntry(repoPair, series, kind, url, summary string, t time.Time) state.ActivityEntry { 11 + return state.ActivityEntry{ 12 + Activity: state.Activity{ 13 + RepoPair: repoPair, 14 + Series: series, 15 + Kind: kind, 16 + URL: url, 17 + Summary: summary, 18 + }, 19 + OccurredAt: t, 20 + } 21 + } 22 + 23 + // TestBuildSeriesRowsAlignsHealthySides verifies the case this test exists 24 + // for: two sides that actually hold the same content, but where graft only 25 + // happened to log the push under one of them, must render the exact same 26 + // number of cells — a real one on the side that logged it, a synthetic one 27 + // (visually identical, only the tooltip differs) on the side that didn't. 28 + func TestBuildSeriesRowsAlignsHealthySides(t *testing.T) { 29 + base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC) 30 + // Both commits are only ever logged under the Forgejo side's own host 31 + // (f1.example) — mirroring the real-world case that motivated this 32 + // fix, where the sync code happens to log an event's URL against one 33 + // particular host even though the content lands everywhere. 34 + entries := []state.ActivityEntry{ 35 + mkEntry("x-f1", "x", "git", "https://f1.example/x/commit/aaa", "commit aaa", base), 36 + mkEntry("x-f1", "x", "git", "https://f1.example/x/commit/bbb", "commit bbb", base.Add(time.Minute)), 37 + } 38 + topology := map[string][]ServerRef{ 39 + "x": { 40 + {Label: "f1.example", URL: "https://f1.example/x", PairName: "x-f1"}, 41 + {Label: "r1.example", URL: "https://r1.example/x", Radicle: true, PairName: "x-f1"}, 42 + }, 43 + } 44 + health := map[string]bool{"x": true} 45 + 46 + rows := buildSeriesRows(entries, health, topology, map[string]PairResult{}) 47 + if len(rows) != 1 { 48 + t.Fatalf("expected 1 series row, got %d", len(rows)) 49 + } 50 + sub := map[string]subRow{} 51 + for _, s := range rows[0].SubRows { 52 + sub[s.Label] = s 53 + } 54 + f1, r1 := sub["f1.example"], sub["r1.example"] 55 + if len(f1.Events) != len(r1.Events) { 56 + t.Fatalf("expected aligned cell counts, got f1=%d r1=%d", len(f1.Events), len(r1.Events)) 57 + } 58 + if len(f1.Events) != 2 { 59 + t.Fatalf("expected 2 cells per side, got %d", len(f1.Events)) 60 + } 61 + // f1 logged both itself: both real (State == ""). 62 + for _, e := range f1.Events { 63 + if e.State != "" { 64 + t.Errorf("expected f1's own events to be real, got State=%q", e.State) 65 + } 66 + } 67 + // r1 never logged either directly: both synthetic, marked "replicated" 68 + // since it's the Radicle side — but same count, same order, so the 69 + // two rows read as aligned rather than one looking "behind". 70 + for _, e := range r1.Events { 71 + if e.State != "replicated" { 72 + t.Errorf("expected r1's filled-in events to be synthetic/replicated, got State=%q", e.State) 73 + } 74 + } 75 + } 76 + 77 + // TestBuildSeriesRowsSkipsFillOnError verifies the other half: a side whose 78 + // last sync pass actually failed must NOT get the synthetic alignment fill 79 + // — it should show only what it has confirmed itself, plus a non-empty 80 + // subRow.Error, so a real problem stays visible instead of being papered 81 + // over by the alignment logic above. 82 + func TestBuildSeriesRowsSkipsFillOnError(t *testing.T) { 83 + base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC) 84 + entries := []state.ActivityEntry{ 85 + mkEntry("x-f1", "x", "git", "https://f1.example/x/commit/aaa", "commit aaa", base), 86 + mkEntry("x-f1", "x", "git", "https://f1.example/x/commit/bbb", "commit bbb", base.Add(time.Minute)), 87 + } 88 + topology := map[string][]ServerRef{ 89 + "x": { 90 + {Label: "f1.example", URL: "https://f1.example/x", PairName: "x-f1"}, 91 + {Label: "f2.example", URL: "https://f2.example/x", PairName: "x-f2"}, 92 + }, 93 + } 94 + health := map[string]bool{"x": false} 95 + pairResults := map[string]PairResult{ 96 + "x-f2": {Name: "x-f2", GitError: "read forgejo head: 502 Bad Gateway"}, 97 + } 98 + 99 + rows := buildSeriesRows(entries, health, topology, pairResults) 100 + sub := map[string]subRow{} 101 + for _, s := range rows[0].SubRows { 102 + sub[s.Label] = s 103 + } 104 + f2 := sub["f2.example"] 105 + if f2.Error == "" { 106 + t.Fatal("expected f2's subRow.Error to be set") 107 + } 108 + if len(f2.Events) != 0 { 109 + t.Fatalf("expected no synthetic fill on an errored side, got %d events", len(f2.Events)) 110 + } 111 + f1 := sub["f1.example"] 112 + if f1.Error != "" { 113 + t.Fatalf("expected f1 (healthy) to have no error, got %q", f1.Error) 114 + } 115 + if len(f1.Events) != 2 { 116 + t.Fatalf("expected f1's own 2 real events, got %d", len(f1.Events)) 117 + } 118 + }
+8 -26
internal/sync/comments.go
··· 18 18 originATProto = "atproto" 19 19 ) 20 20 21 - // mirrorPrefixes marks a comment's body as graft's own output — a copy 22 - // already mirrored from somewhere else — so a comment-sync pass never 23 - // treats it as new source content and mirrors it right back, which would 24 - // loop. This is the fast, always-on guard; commentSeen (the DB-backed 25 - // hash set) is the durable one, needed because a comment's *original* 26 - // copy carries no such prefix and must still be recognized as already 27 - // handled on every later pass. 28 - var mirrorPrefixes = []string{"**via Forgejo:**", "**via Radicle:**", "**via Fediverse,", "**via Bluesky,"} 29 - 30 - func isMirroredComment(body string) bool { 31 - for _, p := range mirrorPrefixes { 32 - if strings.HasPrefix(body, p) { 33 - return true 34 - } 35 - } 36 - return false 37 - } 38 - 39 21 // maxCommentSummary bounds how much of a comment's body lands in the 40 22 // dashboard's summary column — comments run much longer than a commit 41 23 // subject line or an issue title. ··· 61 43 } 62 44 63 45 for _, fc := range fcs { 64 - if isMirroredComment(fc.Body) { 46 + if state.IsMirroredComment(fc.Body) { 65 47 continue 66 48 } 67 49 hash := hashText(fc.Body) ··· 72 54 if seen { 73 55 continue 74 56 } 75 - if err := s.Radicle.CommentIssue(ri.ID, "**via Forgejo:**\n\n"+fc.Body); err != nil { 57 + if err := s.Radicle.CommentIssue(ri.ID, state.MarkMirrored("**via Forgejo:**\n\n"+fc.Body)); err != nil { 76 58 return fmt.Errorf("mirror comment to radicle: %w", err) 77 59 } 78 60 if err := s.State.MarkCommentSeen(s.RepoPair, "issue", ri.ID, hash); err != nil { ··· 88 70 } 89 71 90 72 for _, item := range ri.Comments() { 91 - if isMirroredComment(item.Body) { 73 + if state.IsMirroredComment(item.Body) { 92 74 continue 93 75 } 94 76 hash := hashText(item.Body) ··· 99 81 if seen { 100 82 continue 101 83 } 102 - if err := s.Forgejo.CreateIssueComment(forgejoIndex, "**via Radicle:**\n\n"+item.Body); err != nil { 84 + if err := s.Forgejo.CreateIssueComment(forgejoIndex, state.MarkMirrored("**via Radicle:**\n\n"+item.Body)); err != nil { 103 85 return fmt.Errorf("mirror comment to forgejo: %w", err) 104 86 } 105 87 if err := s.State.MarkCommentSeen(s.RepoPair, "issue", ri.ID, hash); err != nil { ··· 134 116 return fmt.Errorf("list forgejo comments: %w", err) 135 117 } 136 118 for _, fc := range fcs { 137 - if isMirroredComment(fc.Body) { 119 + if state.IsMirroredComment(fc.Body) { 138 120 continue 139 121 } 140 122 hash := hashText(fc.Body) ··· 145 127 if seen { 146 128 continue 147 129 } 148 - if err := s.Radicle.CommentPatch(revisionID, "**via Forgejo:**\n\n"+fc.Body); err != nil { 130 + if err := s.Radicle.CommentPatch(revisionID, state.MarkMirrored("**via Forgejo:**\n\n"+fc.Body)); err != nil { 149 131 return fmt.Errorf("mirror comment to radicle: %w", err) 150 132 } 151 133 if err := s.State.MarkCommentSeen(s.RepoPair, "patch", rp.ID, hash); err != nil { ··· 161 143 } 162 144 163 145 for _, item := range rp.Comments() { 164 - if isMirroredComment(item.Body) { 146 + if state.IsMirroredComment(item.Body) { 165 147 continue 166 148 } 167 149 hash := hashText(item.Body) ··· 172 154 if seen { 173 155 continue 174 156 } 175 - if err := s.Forgejo.CreateIssueComment(forgejoIndex, "**via Radicle:**\n\n"+item.Body); err != nil { 157 + if err := s.Forgejo.CreateIssueComment(forgejoIndex, state.MarkMirrored("**via Radicle:**\n\n"+item.Body)); err != nil { 176 158 return fmt.Errorf("mirror comment to forgejo: %w", err) 177 159 } 178 160 if err := s.State.MarkCommentSeen(s.RepoPair, "patch", rp.ID, hash); err != nil {