Federated via graft
0

Configure Feed

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

Simplify dashboard to kind-only colors; add comment sync from 4 origins

Dashboard: removed the separate "source"/"replicated" cell colors and
legend entries. Cells now always color by event kind (commit, issue,
patch, comment) — the fact that a side holds content without a
graft-logged push of its own (Radicle gossip, an Authorized Integration,
or simply being the origin) is now a tooltip note only, never a visual
category of its own.

New "comment" kind, tracked from four origins, each recorded in a new
activity_log.origin column and shown as a tooltip note (never a color):

- Forgejo <-> Radicle, native: internal/sync/comments.go polls both
sides' comments each pass and cross-mirrors new ones, prefixed
"**via Forgejo:**" / "**via Radicle:**". Dedup via a new comment_seen
table (content hash, scoped per repo_pair+kind+item) plus a prefix
guard against re-mirroring graft's own output — verified live: a
native Forgejo comment mirrored to Radicl<e exactly once, confirmed
stable (no duplicate) across multiple subsequent sync passes.
- ActivityPub: handleReply now logs its existing comment bridge as a
dashboard activity (previously invisible on the dashboard).
- AT Proto: new inbound reply polling (pollBlueskyReplies in
cmd/sync/bluesky_replies.go), using app.bsky.notification.listNotifications.
NOT verified against a live account — none is configured in this
deployment; built directly from AT Proto's published lexicon. Required
extending atproto.Client.Post and sync.BlueskyPoster.Post to return
the new post's AT-URI (new atproto_post table) so a later reply's
parent can be traced back to the activity it was about, and
state.Store.LogActivity to return the new row's id.

Bug caught by a unit test before deploy: the three new comment/reply
truncation helpers sliced on byte index, which both overshot the byte
bound (a 3-byte "…" past a 200-byte cutoff) and could cut a multi-byte
UTF-8 rune in half — fixed to slice on runes, matching the pattern
atproto.Client.Post already used correctly.

+702 -67
+10
TUTORIAL.md
··· 204 204 205 205 `rad node connect` is the first handshake — Radicle's peer discovery (`"peers": {"type": "dynamic"}` in `~/.radicle/config.json`) spreads pairs it already knows, but a never-connected node needs one explicit introduction. `rad seed` is separate: default seeding policy replicates nothing not explicitly told to. Skip either step and the node stays unreachable or empty. 206 206 207 + ## Comments 208 + 209 + `graft` mirrors comments on issues and patches, both directions, from four origins: 210 + 211 + - **Forgejo → Radicle** and **Radicle → Forgejo**: native comments, polled and cross-mirrored each pass. Dedup is by content hash (`comment_seen` table) plus a `**via Forgejo:**` / `**via Radicle:**` prefix on the mirrored copy, so a mirrored comment is never mirrored back. 212 + - **ActivityPub**: a Mastodon reply to one of graft's posts becomes a comment, prefixed `**via Fediverse, @user:**`. 213 + - **AT Proto**: a Bluesky reply, same mechanism, prefixed `**via Bluesky, @handle:**`. Not verified against a live account — built from AT Proto's published lexicon (`app.bsky.notification.listNotifications`), but no Bluesky account existed to test against when this was written. 214 + 215 + Dashboard cells are colored by kind only (commit, issue, patch, comment) — never by origin. Origin shows up as a tooltip note only, on hover. 216 + 207 217 ## Troubleshooting 208 218 209 219 **"token does not have at least one of required scope(s)"** — the Forgejo token is missing `write:issue` or `write:repository`. Regenerate it with both.
+136
cmd/sync/bluesky_replies.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "strings" 7 + 8 + "graft/internal/atproto" 9 + "graft/internal/config" 10 + "graft/internal/state" 11 + ) 12 + 13 + // pollBlueskyReplies checks every series with a Bluesky account configured 14 + // for new replies to posts graft itself made, and bridges each one back 15 + // onto the underlying Forgejo/Radicle item — the AT Proto equivalent of 16 + // activitypub's handleReply. 17 + // 18 + // NOT verified against a live Bluesky account: none was configured in 19 + // this deployment at the time this was written (see BlueskyTarget in 20 + // config.go — a nil block is a no-op, so this simply never runs until 21 + // one exists). Built directly from AT Proto's published 22 + // app.bsky.notification.listNotifications / app.bsky.feed.post lexicon. 23 + // Verify against a real account and a real reply before trusting this 24 + // deeply — see atproto.Notification's doc comment. 25 + func pollBlueskyReplies(pairs []config.RepoPair, live *liveState, st *state.Store, log *slog.Logger) { 26 + seenSeries := map[string]bool{} 27 + for _, pair := range pairs { 28 + if pair.Bluesky == nil { 29 + continue 30 + } 31 + series := pair.Series 32 + if series == "" { 33 + series = pair.Name 34 + } 35 + if seenSeries[series] { 36 + continue 37 + } 38 + seenSeries[series] = true 39 + 40 + appPassword, err := config.ReadToken(pair.Bluesky.AppPasswordFile) 41 + if err != nil { 42 + log.Error("bluesky reply poll: read app password", "series", series, "err", err) 43 + continue 44 + } 45 + if err := pollSeriesReplies(series, pair.Bluesky.Handle, appPassword, pair.Bluesky.PDSHost, live, st, log); err != nil { 46 + log.Error("bluesky reply poll", "series", series, "err", err) 47 + } 48 + } 49 + } 50 + 51 + func pollSeriesReplies(series, handle, appPassword, pdsHost string, live *liveState, st *state.Store, log *slog.Logger) error { 52 + c := atproto.New(pdsHost) 53 + if err := c.Login(handle, appPassword); err != nil { 54 + return fmt.Errorf("login: %w", err) 55 + } 56 + notifications, err := c.ListNotifications() 57 + if err != nil { 58 + return fmt.Errorf("list notifications: %w", err) 59 + } 60 + 61 + watermark, err := st.ATProtoCursor(series) 62 + if err != nil { 63 + return fmt.Errorf("read cursor: %w", err) 64 + } 65 + newest := watermark 66 + 67 + // Newest first per the API's convention; process oldest-first so the 68 + // watermark advances monotonically even if this loop is interrupted 69 + // partway through. 70 + for i := len(notifications) - 1; i >= 0; i-- { 71 + n := notifications[i] 72 + if n.Reason != "reply" || n.Record.Reply == nil { 73 + continue 74 + } 75 + if watermark != "" && n.IndexedAt <= watermark { 76 + continue 77 + } 78 + if n.IndexedAt > newest { 79 + newest = n.IndexedAt 80 + } 81 + 82 + parentURI := n.Record.Reply.Parent.URI 83 + activityID, found, err := st.ATProtoPostActivity(parentURI) 84 + if err != nil { 85 + log.Error("bluesky reply: lookup parent", "series", series, "err", err) 86 + continue 87 + } 88 + if !found { 89 + continue // a reply to something graft didn't post about, or to a reply itself 90 + } 91 + e, err := st.ActivityByID(activityID) 92 + if err != nil { 93 + log.Error("bluesky reply: load activity", "series", series, "id", activityID, "err", err) 94 + continue 95 + } 96 + if e == nil || (e.Kind != "issue" && e.Kind != "patch") { 97 + continue 98 + } 99 + rs, ok := live.syncerForPair(e.RepoPair) 100 + if !ok { 101 + continue 102 + } 103 + 104 + body := fmt.Sprintf("**via Bluesky, @%s:**\n\n%s", n.Author.Handle, strings.TrimSpace(n.Record.Text)) 105 + if err := rs.CommentOnItem(e.Kind, e.ForgejoID, e.RadicleID, body); err != nil { 106 + log.Error("bluesky reply: post comment", "series", series, "repo_pair", e.RepoPair, "err", err) 107 + continue 108 + } 109 + if _, err := st.LogActivity(state.Activity{ 110 + RepoPair: e.RepoPair, Series: e.Series, SeriesURL: e.SeriesURL, 111 + Kind: "comment", Direction: e.Direction, Origin: "atproto", 112 + Summary: truncateATProtoSummary(n.Record.Text), 113 + URL: e.URL, 114 + ForgejoID: e.ForgejoID, RadicleID: e.RadicleID, 115 + }); err != nil { 116 + log.Error("bluesky reply: log activity", "series", series, "err", err) 117 + } 118 + } 119 + 120 + if newest != watermark { 121 + if err := st.SetATProtoCursor(series, newest); err != nil { 122 + return fmt.Errorf("save cursor: %w", err) 123 + } 124 + } 125 + return nil 126 + } 127 + 128 + func truncateATProtoSummary(s string) string { 129 + s = strings.TrimSpace(s) 130 + const max = 200 131 + r := []rune(s) 132 + if len(r) <= max { 133 + return s 134 + } 135 + return string(r[:max-1]) + "…" 136 + }
+1
cmd/sync/main.go
··· 147 147 if apHandler != nil { 148 148 apHandler.DeliverNewActivity() 149 149 } 150 + pollBlueskyReplies(live.pairsSnapshot(), live, st, log) 150 151 } 151 152 152 153 if *listen != "" {
+22
internal/activitypub/handlers.go
··· 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) 319 + return 319 320 } 321 + if _, err := h.store.LogActivity(state.Activity{ 322 + RepoPair: e.RepoPair, Series: e.Series, SeriesURL: e.SeriesURL, 323 + Kind: "comment", Direction: e.Direction, Origin: "activitypub", 324 + Summary: truncateSummary(note.Content), 325 + URL: e.URL, 326 + ForgejoID: e.ForgejoID, RadicleID: e.RadicleID, 327 + }); err != nil { 328 + h.log.Error("ap reply: log activity", "series", series, "repo_pair", e.RepoPair, "err", err) 329 + } 330 + } 331 + 332 + // truncateSummary bounds how much of a fediverse reply's raw HTML content 333 + // lands in the dashboard's summary column. 334 + func truncateSummary(s string) string { 335 + s = stripHTML(s) 336 + const max = 200 337 + r := []rune(s) 338 + if len(r) <= max { 339 + return s 340 + } 341 + return string(r[:max-1]) + "…" 320 342 } 321 343 322 344 // sendAccept replies to a Follow with a signed Accept, embedding the
+71 -10
internal/atproto/client.go
··· 93 93 return nil 94 94 } 95 95 96 - // Post publishes a short text-only post via com.atproto.repo.createRecord. 97 - // text is truncated to maxPostRunes if needed. Login must have succeeded 98 - // first. 99 - func (c *Client) Post(text string) error { 96 + // Post publishes a short text-only post via com.atproto.repo.createRecord 97 + // and returns its AT-URI (at://did/app.bsky.feed.post/rkey) — needed to 98 + // later recognize a reply whose parent points back at it. text is 99 + // truncated to maxPostRunes if needed. Login must have succeeded first. 100 + func (c *Client) Post(text string) (uri string, err error) { 100 101 if c.accessJWT == "" { 101 - return fmt.Errorf("not logged in") 102 + return "", fmt.Errorf("not logged in") 102 103 } 103 104 if utf8.RuneCountInString(text) > maxPostRunes { 104 105 r := []rune(text) ··· 116 117 } 117 118 body, err := json.Marshal(payload) 118 119 if err != nil { 119 - return err 120 + return "", err 120 121 } 121 122 req, err := http.NewRequest(http.MethodPost, c.pdsHost+"/xrpc/com.atproto.repo.createRecord", bytes.NewReader(body)) 122 123 if err != nil { 123 - return err 124 + return "", err 124 125 } 125 126 req.Header.Set("Content-Type", "application/json") 126 127 req.Header.Set("Authorization", "Bearer "+c.accessJWT) 127 128 128 129 resp, err := c.http.Do(req) 129 130 if err != nil { 130 - return fmt.Errorf("create record: %w", err) 131 + return "", fmt.Errorf("create record: %w", err) 132 + } 133 + defer resp.Body.Close() 134 + if resp.StatusCode >= 300 { 135 + b, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) 136 + return "", fmt.Errorf("create record: HTTP %d: %s", resp.StatusCode, truncate(b)) 137 + } 138 + var out struct { 139 + URI string `json:"uri"` 140 + } 141 + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out); err != nil { 142 + return "", fmt.Errorf("decode create record response: %w", err) 143 + } 144 + return out.URI, nil 145 + } 146 + 147 + // Notification is the subset of app.bsky.notification.listNotifications' 148 + // response this client reads. NOT verified against a live PDS — no 149 + // Bluesky account was available to test against when this was written; 150 + // built directly from AT Proto's published lexicon 151 + // (app.bsky.notification.listNotifications / app.bsky.feed.post), which 152 + // is a stable, documented public API, but that is not the same as having 153 + // exercised it. Verify against a real reply before trusting this deeply. 154 + type Notification struct { 155 + URI string `json:"uri"` 156 + Reason string `json:"reason"` // "reply", "like", "repost", "mention", "quote", "follow" 157 + Author struct { 158 + Handle string `json:"handle"` 159 + } `json:"author"` 160 + IndexedAt string `json:"indexedAt"` 161 + Record struct { 162 + Text string `json:"text"` 163 + Reply *struct { 164 + Parent struct { 165 + URI string `json:"uri"` 166 + } `json:"parent"` 167 + } `json:"reply"` 168 + } `json:"record"` 169 + } 170 + 171 + // ListNotifications fetches one page of notifications, newest first. 172 + // Login must have succeeded first. 173 + func (c *Client) ListNotifications() ([]Notification, error) { 174 + if c.accessJWT == "" { 175 + return nil, fmt.Errorf("not logged in") 176 + } 177 + req, err := http.NewRequest(http.MethodGet, c.pdsHost+"/xrpc/app.bsky.notification.listNotifications", nil) 178 + if err != nil { 179 + return nil, err 180 + } 181 + req.Header.Set("Authorization", "Bearer "+c.accessJWT) 182 + 183 + resp, err := c.http.Do(req) 184 + if err != nil { 185 + return nil, fmt.Errorf("list notifications: %w", err) 131 186 } 132 187 defer resp.Body.Close() 133 188 if resp.StatusCode >= 300 { 134 189 b, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) 135 - return fmt.Errorf("create record: HTTP %d: %s", resp.StatusCode, truncate(b)) 190 + return nil, fmt.Errorf("list notifications: HTTP %d: %s", resp.StatusCode, truncate(b)) 136 191 } 137 - return nil 192 + var out struct { 193 + Notifications []Notification `json:"notifications"` 194 + } 195 + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out); err != nil { 196 + return nil, fmt.Errorf("decode notifications: %w", err) 197 + } 198 + return out.Notifications, nil 138 199 }
+19
internal/forgejo/client.go
··· 172 172 return c.do(http.MethodPost, path, req, nil) 173 173 } 174 174 175 + // Comment is the subset of Forgejo's issue-comment object the daemon 176 + // reads. Verified against a live GET on f2. 177 + type Comment struct { 178 + ID int64 `json:"id"` 179 + Body string `json:"body"` 180 + CreatedAt string `json:"created_at"` 181 + } 182 + 183 + // ListIssueComments lists every comment on an issue or pull request — 184 + // same issues-endpoint-for-PRs-too caveat as CreateIssueComment. 185 + func (c *Client) ListIssueComments(index int64) ([]Comment, error) { 186 + var comments []Comment 187 + path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", c.owner, c.repo, index) 188 + if err := c.do(http.MethodGet, path, nil, &comments); err != nil { 189 + return nil, err 190 + } 191 + return comments, nil 192 + } 193 + 175 194 // contentEntry is the subset of Forgejo's contents API response this 176 195 // client reads, shared by both directory listings and single-file fetches. 177 196 type contentEntry struct {
+46 -7
internal/radicle/client.go
··· 86 86 Status string `json:"status"` // "open" or "closed" 87 87 } 88 88 89 + // DiscussionItem is one entry in an issue's or patch revision's 90 + // discussion array — verified against GET /api/v1/repos/:rid/issues on a 91 + // live node: discussion[0] is always the issue's own opening description 92 + // (its id equals the issue's own id), every entry after that is a real 93 + // comment. replyTo is a comment id or null; not currently used, kept for 94 + // when threaded display is worth building. 95 + type DiscussionItem struct { 96 + ID string `json:"id"` 97 + Author Actor `json:"author"` 98 + Body string `json:"body"` 99 + Timestamp int64 `json:"timestamp"` // unix seconds 100 + ReplyTo string `json:"replyTo"` 101 + } 102 + 89 103 // Issue is the subset of radicle-httpd's issue JSON the daemon needs. 90 104 // Verified against GET /api/v1/repos/:rid/issues?status=all on httpd 0.28.0. 91 105 type Issue struct { 92 - ID string `json:"id"` 93 - Title string `json:"title"` 94 - State IssueState `json:"state"` 95 - Author Actor `json:"author"` 96 - Discussion []struct { 97 - Body string `json:"body"` 98 - } `json:"discussion"` 106 + ID string `json:"id"` 107 + Title string `json:"title"` 108 + State IssueState `json:"state"` 109 + Author Actor `json:"author"` 110 + Discussion []DiscussionItem `json:"discussion"` 99 111 } 100 112 101 113 // Body returns the issue's opening comment, if any. ··· 106 118 return i.Discussion[0].Body 107 119 } 108 120 121 + // Comments returns the issue's real comments — everything after the 122 + // opening description in Discussion. 123 + func (i Issue) Comments() []DiscussionItem { 124 + if len(i.Discussion) <= 1 { 125 + return nil 126 + } 127 + return i.Discussion[1:] 128 + } 129 + 109 130 func (c *Client) ListIssues() ([]Issue, error) { 110 131 var issues []Issue 111 132 if err := c.getJSON("/issues?status=all", &issues); err != nil { ··· 151 172 Description string `json:"description"` 152 173 OID string `json:"oid"` // head commit of the revision 153 174 Base string `json:"base"` // commit the patch is based on 175 + // Discussion follows the same shape as an issue's, by analogy 176 + // with radicle-httpd's issue endpoint — NOT independently 177 + // verified against a live node the way the issue schema above 178 + // is (no patch with comments existed to test against at the 179 + // time this was written). Comment sync degrades to "no radicle 180 + // comments seen" rather than erroring if this turns out wrong; 181 + // verify against a real patch before trusting it further. 182 + Discussion []DiscussionItem `json:"discussion"` 154 183 } `json:"revisions"` 184 + } 185 + 186 + // Comments returns a patch's comments, drawn from its first revision — 187 + // the same scoping CommentPatch already documents (a patch's own id 188 + // equals its first revision's id in the common single-revision case). 189 + func (p Patch) Comments() []DiscussionItem { 190 + if len(p.Revisions) == 0 { 191 + return nil 192 + } 193 + return p.Revisions[0].Discussion 155 194 } 156 195 157 196 func (c *Client) ListPatches() ([]Patch, error) {
+117 -14
internal/state/store.go
··· 155 155 created_at TEXT NOT NULL, 156 156 materialized INTEGER NOT NULL DEFAULT 0 157 157 ); 158 + 159 + -- Dedupes comment mirroring across Forgejo and Radicle: a comment body's 160 + -- hash, once mirrored (or recognized as already present on both sides), 161 + -- is recorded here so a later pass never re-posts it back the other way. 162 + -- item_id is the Radicle-side issue/patch id, stable per repo_pair+kind 163 + -- regardless of which Forgejo peer in a federation is being scanned. 164 + CREATE TABLE IF NOT EXISTS comment_seen ( 165 + repo_pair TEXT NOT NULL, 166 + kind TEXT NOT NULL, -- 'issue' or 'patch' 167 + item_id TEXT NOT NULL, -- radicle issue/patch id 168 + hash TEXT NOT NULL, 169 + PRIMARY KEY (repo_pair, kind, item_id, hash) 170 + ); 171 + 172 + -- One row per Bluesky post graft has made, so an inbound reply's parent 173 + -- URI can be traced back to the activity_log entry (and from there, the 174 + -- underlying Forgejo/Radicle item) it was posted about. 175 + CREATE TABLE IF NOT EXISTS atproto_post ( 176 + uri TEXT PRIMARY KEY, 177 + activity_id INTEGER NOT NULL, 178 + series TEXT NOT NULL, 179 + created_at TEXT NOT NULL 180 + ); 181 + 182 + -- How far AT Proto notification polling has gotten for each series' 183 + -- Bluesky account, so a restart doesn't re-process old replies. 184 + CREATE TABLE IF NOT EXISTS atproto_cursor ( 185 + series TEXT PRIMARY KEY, 186 + cursor TEXT NOT NULL DEFAULT '' 187 + ); 158 188 `) 159 189 if err != nil { 160 190 return err ··· 168 198 `ALTER TABLE activity_log ADD COLUMN series_url TEXT NOT NULL DEFAULT ''`, 169 199 `ALTER TABLE activity_log ADD COLUMN forgejo_id INTEGER NOT NULL DEFAULT 0`, 170 200 `ALTER TABLE activity_log ADD COLUMN radicle_id TEXT NOT NULL DEFAULT ''`, 201 + `ALTER TABLE activity_log ADD COLUMN origin TEXT NOT NULL DEFAULT ''`, 171 202 } { 172 203 if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { 173 204 return err ··· 261 292 // this sync; "" if none is known. 262 293 ForgejoID int64 // issue/PR number on Forgejo, for kind "issue"/"patch"; 0 for "git" 263 294 RadicleID string // issue/patch id on Radicle, for kind "issue"/"patch"; "" for "git" 295 + // Origin names where a "comment" kind entry actually happened — 296 + // "forgejo", "radicle", "activitypub", or "atproto". Empty for every 297 + // other kind. Display-only: shown in the dashboard tooltip, never 298 + // drives a cell's color (see status.event.Origin). 299 + Origin string 264 300 } 265 301 266 302 // Direction values for Activity.Direction, kept as constants so callers ··· 271 307 RadicleToForgejo = "radicle_to_forgejo" 272 308 ) 273 309 274 - // LogActivity records one Activity. Call it once per commit/issue/patch, 275 - // not once per sync pass. 276 - func (s *Store) LogActivity(a Activity) error { 277 - _, err := s.db.Exec(` 278 - INSERT INTO activity_log (repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at) 279 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 280 - `, a.RepoPair, a.Kind, a.Direction, a.Summary, a.URL, a.Series, a.SeriesURL, a.ForgejoID, a.RadicleID, time.Now().UTC().Format(time.RFC3339)) 281 - return err 310 + // LogActivity records one Activity and returns its new row id — used by 311 + // the Bluesky bridge to link a posted note back to what it was about, so 312 + // a later reply can be traced to the right item. Call it once per 313 + // commit/issue/patch/comment, not once per sync pass. 314 + func (s *Store) LogActivity(a Activity) (int64, error) { 315 + res, err := s.db.Exec(` 316 + INSERT INTO activity_log (repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, origin, occurred_at) 317 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 318 + `, a.RepoPair, a.Kind, a.Direction, a.Summary, a.URL, a.Series, a.SeriesURL, a.ForgejoID, a.RadicleID, a.Origin, time.Now().UTC().Format(time.RFC3339)) 319 + if err != nil { 320 + return 0, err 321 + } 322 + return res.LastInsertId() 282 323 } 283 324 284 325 // ActivityEntry is one row of activity_log. ··· 291 332 // ActivitySince returns every activity entry at or after since, newest first. 292 333 func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) { 293 334 rows, err := s.db.Query(` 294 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 335 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, origin, occurred_at FROM activity_log 295 336 WHERE occurred_at >= ? 296 337 ORDER BY occurred_at DESC 297 338 `, since.UTC().Format(time.RFC3339)) ··· 306 347 // ActivityPub outbox. 307 348 func (s *Store) ActivityForSeries(series string, limit int) ([]ActivityEntry, error) { 308 349 rows, err := s.db.Query(` 309 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 350 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, origin, occurred_at FROM activity_log 310 351 WHERE series = ? 311 352 ORDER BY occurred_at DESC 312 353 LIMIT ? ··· 322 363 // item it describes. 323 364 func (s *Store) ActivityByID(id int64) (*ActivityEntry, error) { 324 365 row := s.db.QueryRow(` 325 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 366 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, origin, occurred_at FROM activity_log 326 367 WHERE id = ? 327 368 `, id) 328 369 var e ActivityEntry 329 370 var occurredAt string 330 - err := row.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &occurredAt) 371 + err := row.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &e.Origin, &occurredAt) 331 372 if err == sql.ErrNoRows { 332 373 return nil, nil 333 374 } ··· 347 388 for rows.Next() { 348 389 var e ActivityEntry 349 390 var occurredAt string 350 - if err := rows.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &occurredAt); err != nil { 391 + if err := rows.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &e.Origin, &occurredAt); err != nil { 351 392 return nil, err 352 393 } 353 394 var err error ··· 484 525 // id > afterID, oldest first — the delivery order for ActivityPub. 485 526 func (s *Store) NewActivityForSeries(series string, afterID int64) ([]ActivityEntry, error) { 486 527 rows, err := s.db.Query(` 487 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 528 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, origin, occurred_at FROM activity_log 488 529 WHERE series = ? AND id > ? 489 530 ORDER BY id ASC 490 531 `, series, afterID) ··· 677 718 } 678 719 return 0 679 720 } 721 + 722 + // CommentSeen reports whether a comment body's hash has already been 723 + // dealt with (mirrored, or recognized as already present on both sides) 724 + // for one item, so a comment sync pass never re-posts the same content 725 + // back and forth between Forgejo and Radicle. 726 + func (s *Store) CommentSeen(repoPair, kind, itemID, hash string) (bool, error) { 727 + row := s.db.QueryRow(`SELECT 1 FROM comment_seen WHERE repo_pair = ? AND kind = ? AND item_id = ? AND hash = ?`, repoPair, kind, itemID, hash) 728 + var one int 729 + err := row.Scan(&one) 730 + if err == sql.ErrNoRows { 731 + return false, nil 732 + } 733 + return err == nil, err 734 + } 735 + 736 + // MarkCommentSeen records a comment body's hash as dealt with. 737 + func (s *Store) MarkCommentSeen(repoPair, kind, itemID, hash string) error { 738 + _, err := s.db.Exec(`INSERT OR IGNORE INTO comment_seen (repo_pair, kind, item_id, hash) VALUES (?, ?, ?, ?)`, repoPair, kind, itemID, hash) 739 + return err 740 + } 741 + 742 + // SaveATProtoPost records a Bluesky post graft made, so a later reply's 743 + // parent URI can be traced back to the activity_log entry it was about. 744 + func (s *Store) SaveATProtoPost(uri string, activityID int64, series string) error { 745 + _, err := s.db.Exec(`INSERT OR IGNORE INTO atproto_post (uri, activity_id, series, created_at) VALUES (?, ?, ?, ?)`, 746 + uri, activityID, series, time.Now().UTC().Format(time.RFC3339)) 747 + return err 748 + } 749 + 750 + // ATProtoPostActivity looks up which activity_log entry a Bluesky post 751 + // URI was about, for resolving an inbound reply back to the underlying 752 + // Forgejo/Radicle item. 753 + func (s *Store) ATProtoPostActivity(uri string) (int64, bool, error) { 754 + row := s.db.QueryRow(`SELECT activity_id FROM atproto_post WHERE uri = ?`, uri) 755 + var id int64 756 + err := row.Scan(&id) 757 + if err == sql.ErrNoRows { 758 + return 0, false, nil 759 + } 760 + return id, err == nil, err 761 + } 762 + 763 + // ATProtoCursor returns how far notification polling has gotten for a 764 + // series' Bluesky account ("" if never polled). 765 + func (s *Store) ATProtoCursor(series string) (string, error) { 766 + row := s.db.QueryRow(`SELECT cursor FROM atproto_cursor WHERE series = ?`, series) 767 + var c string 768 + err := row.Scan(&c) 769 + if err == sql.ErrNoRows { 770 + return "", nil 771 + } 772 + return c, err 773 + } 774 + 775 + // SetATProtoCursor records how far notification polling has gotten. 776 + func (s *Store) SetATProtoCursor(series, cursor string) error { 777 + _, err := s.db.Exec(` 778 + INSERT INTO atproto_cursor (series, cursor) VALUES (?, ?) 779 + ON CONFLICT (series) DO UPDATE SET cursor = excluded.cursor 780 + `, series, cursor) 781 + return err 782 + }
+47 -19
internal/status/status.go
··· 323 323 // normal commit/issue/patch cell, just in a different color, with the 324 324 // tooltip naming where it actually happened. 325 325 type event struct { 326 - Kind string // "git", "issue", "patch" — drives the cell's color when State is unset 327 - Label string // "Commit", "Issue", "Patch" 328 - Text string 329 - URL string 330 - Server string // which mirrored side this happened on, e.g. "f1", "alice" 331 - When string // human-readable timestamp, for the tooltip 332 - State string // "" for a real event; "source" or "replicated" for a synthetic one — drives the cell's color instead of Kind 326 + Kind string // "git", "issue", "patch", "comment" — always drives the cell's color 327 + Label string // "Commit", "Issue", "Patch", "Comment" 328 + Text string 329 + URL string 330 + Server string // which mirrored side this happened on, e.g. "f1", "alice" 331 + When string // human-readable timestamp, for the tooltip 332 + // State and OriginLabel exist only for a synthetic cell — one 333 + // standing in for content a side plainly holds without graft having 334 + // logged a push there directly (see buildSeriesRows). Never drives 335 + // the cell's color (Kind always does); surfaces only as a tooltip 336 + // note explaining why this side has no event of its own. 337 + State string // "" for a real event; "source" or "replicated" for a synthetic one 333 338 OriginLabel string // for a synthetic cell, the sub-row label the real event was actually logged on 339 + // Origin is set on a real "comment" kind event: where it actually 340 + // happened — "Forgejo", "Radicle", "Fediverse (ActivityPub)", or 341 + // "Bluesky (AT Proto)". Tooltip note only, same as State/OriginLabel. 342 + Origin string 343 + } 344 + 345 + // originLabel turns a stored activity_log.origin value into display 346 + // text for the tooltip. 347 + func originLabel(origin string) string { 348 + switch origin { 349 + case "forgejo": 350 + return "Forgejo" 351 + case "radicle": 352 + return "Radicle" 353 + case "activitypub": 354 + return "Fediverse (ActivityPub)" 355 + case "atproto": 356 + return "Bluesky (AT Proto)" 357 + default: 358 + return "" 359 + } 334 360 } 335 361 336 362 type commitLine struct { ··· 551 577 URL: e.URL, 552 578 Server: server, 553 579 When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), 580 + Origin: originLabel(e.Origin), 554 581 }) 555 582 } 556 583 return out ··· 564 591 return "Issue" 565 592 case "patch": 566 593 return "Patch" 594 + case "comment": 595 + return "Comment" 567 596 default: 568 597 return kind 569 598 } ··· 688 717 --kind-git: #0052cc; 689 718 --kind-issue: #00875a; 690 719 --kind-patch: #6554c0; 691 - --kind-source: #ff991f; 692 - --kind-replicated: #00b8d9; 720 + --kind-comment: #ff991f; 693 721 --bad: #de350b; 694 722 --good: #36b37e; 695 723 } ··· 734 762 .legend .sw[data-kind="git"] { background: var(--kind-git); } 735 763 .legend .sw[data-kind="issue"] { background: var(--kind-issue); } 736 764 .legend .sw[data-kind="patch"] { background: var(--kind-patch); } 737 - .legend .sw[data-kind="source"] { background: var(--kind-source); } 738 - .legend .sw[data-kind="replicated"] { background: var(--kind-replicated); } 765 + .legend .sw[data-kind="comment"] { background: var(--kind-comment); } 739 766 .heatmap { display: flex; flex-direction: column; gap: .15rem; } 740 767 .series-group { border-bottom: 1px solid var(--border); } 741 768 .series-group:last-child { border-bottom: none; } ··· 770 797 .cell[data-kind="git"] { background: var(--kind-git); } 771 798 .cell[data-kind="issue"] { background: var(--kind-issue); } 772 799 .cell[data-kind="patch"] { background: var(--kind-patch); } 773 - .cell[data-kind="source"] { background: var(--kind-source); } 774 - .cell[data-kind="replicated"] { background: var(--kind-replicated); } 800 + .cell[data-kind="comment"] { background: var(--kind-comment); } 775 801 776 802 .tip { 777 803 display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%); ··· 842 868 <span><i class="sw" data-kind="git"></i>Commit</span> 843 869 <span><i class="sw" data-kind="issue"></i>Issue</span> 844 870 <span><i class="sw" data-kind="patch"></i>Patch</span> 845 - <span><i class="sw" data-kind="source"></i>Source</span> 846 - <span><i class="sw" data-kind="replicated"></i>Replicated</span> 871 + <span><i class="sw" data-kind="comment"></i>Comment</span> 847 872 </div> 848 873 <div class="fed-actions"> 849 874 <a class="btn-outline" href="/add-peer">+ Add peer</a> ··· 868 893 {{end}} 869 894 <div class="days"> 870 895 {{range .Events}} 871 - <div class="cell" data-kind="{{if .State}}{{.State}}{{else}}{{.Kind}}{{end}}"> 896 + <div class="cell" data-kind="{{.Kind}}"> 872 897 <div class="tip"> 873 898 <div class="tip-date">{{.When}}</div> 874 899 {{if .URL}} 875 - <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}{{if eq .State "source"}} · source{{else if eq .State "replicated"}} · replicated{{end}}</span>{{.Text}}</a> 900 + <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a> 876 901 {{else}} 877 - <span class="tip-row"><span class="tip-kind">{{.Label}}{{if eq .State "source"}} · source{{else if eq .State "replicated"}} · replicated{{end}}</span>{{.Text}}</span> 902 + <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span> 878 903 {{end}} 879 904 {{if eq .State "source"}} 880 - <div class="tip-note">origin of this content — graft mirrored it out from here, via {{.OriginLabel}}</div> 905 + <div class="tip-note">origin of this content — mirrored out from here, via {{.OriginLabel}}</div> 881 906 {{else if eq .State "replicated"}} 882 907 <div class="tip-note">replicated here without a graft-logged push — originally pushed via {{.OriginLabel}}, {{.When}}</div> 908 + {{end}} 909 + {{if .Origin}} 910 + <div class="tip-note">via {{.Origin}}</div> 883 911 {{end}} 884 912 </div> 885 913 </div>
+9 -7
internal/sync/bluesky.go
··· 17 17 PDSHost string 18 18 } 19 19 20 - // Post logs in fresh and publishes text. A new session per post is 20 + // Post logs in fresh and publishes text, returning the new post's AT-URI 21 + // (used to recognize a later reply to it). A new session per post is 21 22 // wasteful but simple and avoids session-expiry bookkeeping — acceptable 22 23 // given how infrequently a single pair mirrors new content. 23 - func (b *BlueskyPoster) Post(text string) error { 24 + func (b *BlueskyPoster) Post(text string) (uri string, err error) { 24 25 if b == nil { 25 - return nil 26 + return "", nil 26 27 } 27 28 c := atproto.New(b.PDSHost) 28 29 if err := c.Login(b.Handle, b.AppPassword); err != nil { 29 - return fmt.Errorf("bluesky login: %w", err) 30 + return "", fmt.Errorf("bluesky login: %w", err) 30 31 } 31 - if err := c.Post(text); err != nil { 32 - return fmt.Errorf("bluesky post: %w", err) 32 + uri, err = c.Post(text) 33 + if err != nil { 34 + return "", fmt.Errorf("bluesky post: %w", err) 33 35 } 34 - return nil 36 + return uri, nil 35 37 }
+190
internal/sync/comments.go
··· 1 + package sync 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "graft/internal/radicle" 8 + "graft/internal/state" 9 + ) 10 + 11 + // Origin values for a "comment" kind activity_log row — where the 12 + // comment actually happened, shown in the dashboard tooltip only (see 13 + // internal/status's event.Origin). 14 + const ( 15 + originForgejo = "forgejo" 16 + originRadicle = "radicle" 17 + originActivityPub = "activitypub" 18 + originATProto = "atproto" 19 + ) 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 + // maxCommentSummary bounds how much of a comment's body lands in the 40 + // dashboard's summary column — comments run much longer than a commit 41 + // subject line or an issue title. 42 + const maxCommentSummary = 200 43 + 44 + func truncateComment(body string) string { 45 + body = strings.TrimSpace(body) 46 + r := []rune(body) 47 + if len(r) <= maxCommentSummary { 48 + return body 49 + } 50 + return string(r[:maxCommentSummary-1]) + "…" 51 + } 52 + 53 + // syncIssueComments cross-mirrors an issue's comments between Forgejo and 54 + // Radicle. ri is the already-fetched Radicle issue (its Discussion is 55 + // used directly — no extra API call), forgejoIndex identifies the same 56 + // issue on Forgejo. 57 + func (s *IssueSyncer) syncIssueComments(forgejoIndex int64, ri radicle.Issue) error { 58 + fcs, err := s.Forgejo.ListIssueComments(forgejoIndex) 59 + if err != nil { 60 + return fmt.Errorf("list forgejo comments: %w", err) 61 + } 62 + 63 + for _, fc := range fcs { 64 + if isMirroredComment(fc.Body) { 65 + continue 66 + } 67 + hash := hashText(fc.Body) 68 + seen, err := s.State.CommentSeen(s.RepoPair, "issue", ri.ID, hash) 69 + if err != nil { 70 + return err 71 + } 72 + if seen { 73 + continue 74 + } 75 + if err := s.Radicle.CommentIssue(ri.ID, "**via Forgejo:**\n\n"+fc.Body); err != nil { 76 + return fmt.Errorf("mirror comment to radicle: %w", err) 77 + } 78 + if err := s.State.MarkCommentSeen(s.RepoPair, "issue", ri.ID, hash); err != nil { 79 + return err 80 + } 81 + s.State.LogActivity(state.Activity{ 82 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 83 + Kind: "comment", Direction: state.ForgejoToRadicle, Origin: originForgejo, 84 + Summary: truncateComment(fc.Body), 85 + URL: s.RadicleWebURL + "/issues/" + ri.ID, 86 + ForgejoID: forgejoIndex, RadicleID: ri.ID, 87 + }) 88 + } 89 + 90 + for _, item := range ri.Comments() { 91 + if isMirroredComment(item.Body) { 92 + continue 93 + } 94 + hash := hashText(item.Body) 95 + seen, err := s.State.CommentSeen(s.RepoPair, "issue", ri.ID, hash) 96 + if err != nil { 97 + return err 98 + } 99 + if seen { 100 + continue 101 + } 102 + if err := s.Forgejo.CreateIssueComment(forgejoIndex, "**via Radicle:**\n\n"+item.Body); err != nil { 103 + return fmt.Errorf("mirror comment to forgejo: %w", err) 104 + } 105 + if err := s.State.MarkCommentSeen(s.RepoPair, "issue", ri.ID, hash); err != nil { 106 + return err 107 + } 108 + s.State.LogActivity(state.Activity{ 109 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 110 + Kind: "comment", Direction: state.RadicleToForgejo, Origin: originRadicle, 111 + Summary: truncateComment(item.Body), 112 + URL: fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, forgejoIndex), 113 + ForgejoID: forgejoIndex, RadicleID: ri.ID, 114 + }) 115 + } 116 + return nil 117 + } 118 + 119 + // syncPatchComments is syncIssueComments' patch equivalent. Radicle-side 120 + // comments come from the patch's first revision (Patch.Comments; see its 121 + // doc comment — the same single-revision scoping CommentPatch already 122 + // has) and are NOT independently verified against a live node the way 123 + // issue comments are (see the Discussion field's doc comment in 124 + // radicle/client.go). Degrades to silently seeing no Radicle comments 125 + // if that schema assumption is wrong, rather than erroring. 126 + func (s *PatchSyncer) syncPatchComments(forgejoIndex int64, rp radicle.Patch) error { 127 + if len(rp.Revisions) == 0 { 128 + return nil 129 + } 130 + revisionID := rp.Revisions[0].ID 131 + 132 + fcs, err := s.Forgejo.ListIssueComments(forgejoIndex) 133 + if err != nil { 134 + return fmt.Errorf("list forgejo comments: %w", err) 135 + } 136 + for _, fc := range fcs { 137 + if isMirroredComment(fc.Body) { 138 + continue 139 + } 140 + hash := hashText(fc.Body) 141 + seen, err := s.State.CommentSeen(s.RepoPair, "patch", rp.ID, hash) 142 + if err != nil { 143 + return err 144 + } 145 + if seen { 146 + continue 147 + } 148 + if err := s.Radicle.CommentPatch(revisionID, "**via Forgejo:**\n\n"+fc.Body); err != nil { 149 + return fmt.Errorf("mirror comment to radicle: %w", err) 150 + } 151 + if err := s.State.MarkCommentSeen(s.RepoPair, "patch", rp.ID, hash); err != nil { 152 + return err 153 + } 154 + s.State.LogActivity(state.Activity{ 155 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 156 + Kind: "comment", Direction: state.ForgejoToRadicle, Origin: originForgejo, 157 + Summary: truncateComment(fc.Body), 158 + URL: s.RadicleWebURL + "/patches/" + rp.ID, 159 + ForgejoID: forgejoIndex, RadicleID: rp.ID, 160 + }) 161 + } 162 + 163 + for _, item := range rp.Comments() { 164 + if isMirroredComment(item.Body) { 165 + continue 166 + } 167 + hash := hashText(item.Body) 168 + seen, err := s.State.CommentSeen(s.RepoPair, "patch", rp.ID, hash) 169 + if err != nil { 170 + return err 171 + } 172 + if seen { 173 + continue 174 + } 175 + if err := s.Forgejo.CreateIssueComment(forgejoIndex, "**via Radicle:**\n\n"+item.Body); err != nil { 176 + return fmt.Errorf("mirror comment to forgejo: %w", err) 177 + } 178 + if err := s.State.MarkCommentSeen(s.RepoPair, "patch", rp.ID, hash); err != nil { 179 + return err 180 + } 181 + s.State.LogActivity(state.Activity{ 182 + RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 183 + Kind: "comment", Direction: state.RadicleToForgejo, Origin: originRadicle, 184 + Summary: truncateComment(item.Body), 185 + URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, forgejoIndex), 186 + ForgejoID: forgejoIndex, RadicleID: rp.ID, 187 + }) 188 + } 189 + return nil 190 + }
+4 -2
internal/sync/git.go
··· 292 292 url = g.ForgejoWebURL + "/commit/" + full 293 293 } 294 294 } 295 - g.State.LogActivity(state.Activity{ 295 + id, _ := g.State.LogActivity(state.Activity{ 296 296 RepoPair: g.RepoPair, Series: g.Series, SeriesURL: g.SeriesURL, 297 297 Kind: "git", Direction: direction, Summary: summary, URL: url, 298 298 }) ··· 303 303 if url != "" { 304 304 text += "\n" + url 305 305 } 306 - g.Bluesky.Post(text) 306 + if postURI, err := g.Bluesky.Post(text); err == nil && postURI != "" { 307 + g.State.SaveATProtoPost(postURI, id, g.Series) 308 + } 307 309 } 308 310 }
+11 -2
internal/sync/issues.go
··· 36 36 if err != nil { 37 37 return fmt.Errorf("list radicle issues: %w", err) 38 38 } 39 + radicleByID := make(map[string]radicle.Issue, len(radicleIssues)) 40 + for _, ri := range radicleIssues { 41 + radicleByID[ri.ID] = ri 42 + } 39 43 40 44 for _, fi := range forgejoIssues { 41 - if err := s.mirrorForgejoToRadicle(fi); err != nil { 45 + if err := s.mirrorForgejoToRadicle(fi, radicleByID); err != nil { 42 46 return fmt.Errorf("mirror forgejo issue #%d: %w", fi.Index, err) 43 47 } 44 48 } ··· 50 54 return nil 51 55 } 52 56 53 - func (s *IssueSyncer) mirrorForgejoToRadicle(fi forgejo.Issue) error { 57 + func (s *IssueSyncer) mirrorForgejoToRadicle(fi forgejo.Issue, radicleByID map[string]radicle.Issue) error { 54 58 existing, err := s.State.FindByForgejoID(s.RepoPair, "issue", fi.Index) 55 59 if err != nil { 56 60 return err 57 61 } 58 62 if existing != nil { 63 + if ri, ok := radicleByID[existing.RadicleID]; ok { 64 + if err := s.syncIssueComments(fi.Index, ri); err != nil { 65 + return fmt.Errorf("sync comments: %w", err) 66 + } 67 + } 59 68 return nil // already mirrored; edits not yet synced (see package doc) 60 69 } 61 70
+19 -6
internal/sync/patches.go
··· 58 58 if err != nil { 59 59 return fmt.Errorf("list radicle patches: %w", err) 60 60 } 61 + radicleByID := make(map[string]radicle.Patch, len(patches)) 62 + for _, p := range patches { 63 + radicleByID[p.ID] = p 64 + } 61 65 62 66 for _, pr := range prs { 63 - if err := s.mirrorForgejoToRadicle(pr); err != nil { 67 + if err := s.mirrorForgejoToRadicle(pr, radicleByID); err != nil { 64 68 return fmt.Errorf("mirror forgejo PR #%d: %w", pr.Index, err) 65 69 } 66 70 } ··· 74 78 75 79 var patchOpenedRe = regexp.MustCompile(`Patch ([0-9a-f]{40}) opened`) 76 80 77 - func (s *PatchSyncer) mirrorForgejoToRadicle(pr forgejo.PullRequest) error { 81 + func (s *PatchSyncer) mirrorForgejoToRadicle(pr forgejo.PullRequest, radicleByID map[string]radicle.Patch) error { 78 82 existing, err := s.State.FindByForgejoID(s.RepoPair, "patch", pr.Index) 79 83 if err != nil { 80 84 return err 81 85 } 82 86 if existing != nil { 87 + if rp, ok := radicleByID[existing.RadicleID]; ok { 88 + if err := s.syncPatchComments(pr.Index, rp); err != nil { 89 + return fmt.Errorf("sync comments: %w", err) 90 + } 91 + } 83 92 return nil 84 93 } 85 94 ··· 122 131 }); err != nil { 123 132 return err 124 133 } 125 - s.State.LogActivity(state.Activity{ 134 + id, _ := s.State.LogActivity(state.Activity{ 126 135 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 127 136 Kind: "patch", Direction: state.ForgejoToRadicle, Summary: pr.Title, 128 137 URL: s.RadicleWebURL + "/patches/" + m[1], 129 138 ForgejoID: pr.Index, RadicleID: m[1], 130 139 }) 131 - s.Bluesky.Post(s.Series + ": patch opened — " + pr.Title + "\n" + s.RadicleWebURL + "/patches/" + m[1]) 140 + if uri, err := s.Bluesky.Post(s.Series + ": patch opened — " + pr.Title + "\n" + s.RadicleWebURL + "/patches/" + m[1]); err == nil && uri != "" { 141 + s.State.SaveATProtoPost(uri, id, s.Series) 142 + } 132 143 return nil 133 144 } 134 145 ··· 169 180 }); err != nil { 170 181 return err 171 182 } 172 - s.State.LogActivity(state.Activity{ 183 + id, _ := s.State.LogActivity(state.Activity{ 173 184 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 174 185 Kind: "patch", Direction: state.RadicleToForgejo, Summary: p.Title, 175 186 URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index), 176 187 ForgejoID: pr.Index, RadicleID: p.ID, 177 188 }) 178 - s.Bluesky.Post(fmt.Sprintf("%s: patch opened — %s\n%s/pulls/%d", s.Series, p.Title, s.ForgejoWebURL, pr.Index)) 189 + if uri, err := s.Bluesky.Post(fmt.Sprintf("%s: patch opened — %s\n%s/pulls/%d", s.Series, p.Title, s.ForgejoWebURL, pr.Index)); err == nil && uri != "" { 190 + s.State.SaveATProtoPost(uri, id, s.Series) 191 + } 179 192 return nil 180 193 } 181 194