Federated via graft
0

Configure Feed

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

v2 M3: comment bridge — fediverse replies become real comments

activity_log now records the forgejo_id/radicle_id of the issue/patch
each entry is about, so a reply's inReplyTo can be resolved back to the
exact item it's a comment on.

Forgejo: CreateIssueComment (POST .../issues/{index}/comments — verified
live, and confirmed the same endpoint takes PR comments too). Radicle:
CommentIssue and CommentPatch via the rad CLI (verified against
`rad issue/patch comment --help` on a live node first, rather than
guessing the syntax — patch comments take a revision id, not the patch's
own id, which the Patch/Revision struct now also carries).

The inbox now handles Create{Note} whose inReplyTo points at one of our
own Notes: strips the reply's HTML, prefixes it with the replying
account's handle, and posts it as a comment on both the Forgejo and
Radicle sides of whichever specific pair logged that entry.

Verified live end-to-end without a real fediverse account, using one of
graft's own already-published actors as the signed sender (its key never
leaves the container — a small Go program run via `pct exec` reads it
locally and signs the request there): the reply landed as a comment on
both f2's issue and the Radicle issue, correctly attributed.

+205 -29
+10
cmd/sync/main.go
··· 5 5 6 6 import ( 7 7 "flag" 8 + "fmt" 8 9 "log/slog" 9 10 "net/http" 10 11 "net/url" ··· 46 47 } 47 48 48 49 syncers := make([]*sync.RepoSyncer, 0, len(cfg.Repos)) 50 + syncersByPair := map[string]*sync.RepoSyncer{} 49 51 for _, pair := range cfg.Repos { 50 52 rs, err := sync.New(pair, st, sync.WorkDirFor(stateDir, pair.Name)) 51 53 if err != nil { ··· 53 55 os.Exit(1) 54 56 } 55 57 syncers = append(syncers, rs) 58 + syncersByPair[pair.Name] = rs 56 59 } 57 60 58 61 topology := buildTopology(cfg) ··· 73 76 func(series string) bool { 74 77 _, ok := topology[series] 75 78 return ok 79 + }, 80 + func(repoPair, kind string, forgejoID int64, radicleID, body string) error { 81 + rs, ok := syncersByPair[repoPair] 82 + if !ok { 83 + return fmt.Errorf("unknown repo pair %q", repoPair) 84 + } 85 + return rs.CommentOnItem(kind, forgejoID, radicleID, body) 76 86 }, 77 87 ) 78 88 }
+68 -16
internal/activitypub/handlers.go
··· 3 3 import ( 4 4 "crypto/rsa" 5 5 "encoding/json" 6 + "fmt" 6 7 "io" 7 8 "log/slog" 8 9 "net/http" ··· 21 22 22 23 // Handler serves the ActivityPub surface for every series: WebFinger, 23 24 // actor, outbox, individual notes, followers, and an inbox that accepts 24 - // Follow/Undo{Follow} (replies land here too but are ignored until the 25 - // comment bridge, M3). 25 + // Follow/Undo{Follow} and bridges replies back as Forgejo/Radicle 26 + // comments. 26 27 type Handler struct { 27 28 store *state.Store 28 29 host string 29 30 log *slog.Logger 30 - // repoURL and known are supplied by the caller (cmd/sync/main.go), 31 - // which already knows every series' topology — avoids this package 31 + // repoURL, known, and postComment are supplied by the caller 32 + // (cmd/sync/main.go), which already knows every series' topology and 33 + // holds the actual sync.RepoSyncer instances — avoids this package 32 34 // needing to know about config.Config or sync.RepoSyncer at all. 33 - repoURL func(series string) string 34 - known func(series string) bool 35 - client *http.Client 35 + repoURL func(series string) string 36 + known func(series string) bool 37 + postComment func(repoPair, kind string, forgejoID int64, radicleID, body string) error 38 + client *http.Client 36 39 } 37 40 38 - func NewHandler(store *state.Store, host string, log *slog.Logger, repoURL func(series string) string, known func(series string) bool) *Handler { 41 + func NewHandler( 42 + store *state.Store, 43 + host string, 44 + log *slog.Logger, 45 + repoURL func(series string) string, 46 + known func(series string) bool, 47 + postComment func(repoPair, kind string, forgejoID int64, radicleID, body string) error, 48 + ) *Handler { 39 49 return &Handler{ 40 - store: store, 41 - host: host, 42 - log: log, 43 - repoURL: repoURL, 44 - known: known, 45 - client: &http.Client{Timeout: 15 * time.Second}, 50 + store: store, 51 + host: host, 52 + log: log, 53 + repoURL: repoURL, 54 + known: known, 55 + postComment: postComment, 56 + client: &http.Client{Timeout: 15 * time.Second}, 46 57 } 47 58 } 48 59 ··· 255 266 h.log.Error("ap inbox: remove follower", "series", series, "actor", act.Actor, "err", err) 256 267 } 257 268 } 269 + case "Create": 270 + var note inboxNote 271 + if err := json.Unmarshal(act.Object, &note); err == nil && note.Type == "Note" && note.InReplyTo != "" { 272 + h.handleReply(series, remoteActor, note) 273 + } 258 274 default: 259 - // Replies (Create{Note}) land here too but aren't handled until 260 - // the comment bridge (v2 M3) — accepted and ignored for now. 275 + // Anything else (Like, Announce, ...) is accepted and ignored. 261 276 } 262 277 263 278 w.WriteHeader(http.StatusAccepted) 279 + } 280 + 281 + // inboxNote is the subset of an inbound reply's Note object this handler 282 + // reads. 283 + type inboxNote struct { 284 + Type string `json:"type"` 285 + InReplyTo string `json:"inReplyTo"` 286 + Content string `json:"content"` 287 + } 288 + 289 + // handleReply bridges a fediverse reply to one of our Notes back onto the 290 + // Forgejo issue/PR and Radicle issue/patch it's actually about. Create-only 291 + // and best-effort, same trust model as the rest of graft's mirroring: never 292 + // blocks or fails the inbox response, just logs and moves on. 293 + func (h *Handler) handleReply(series string, remoteActor *Actor, note inboxNote) { 294 + noteSeries, entryID, ok := ParseNoteURI(h.host, note.InReplyTo) 295 + if !ok || noteSeries != series { 296 + return 297 + } 298 + e, err := h.store.ActivityByID(entryID) 299 + if err != nil { 300 + h.log.Error("ap reply: load activity", "series", series, "id", entryID, "err", err) 301 + return 302 + } 303 + if e == nil || (e.Kind != "issue" && e.Kind != "patch") { 304 + return // nothing to comment on — a reply to a git commit note, or the entry vanished 305 + } 306 + 307 + who := remoteActor.PreferredUsername 308 + if who == "" { 309 + who = remoteActor.Name 310 + } 311 + body := fmt.Sprintf("**via Fediverse, @%s:**\n\n%s", who, stripHTML(note.Content)) 312 + 313 + if err := h.postComment(e.RepoPair, e.Kind, e.ForgejoID, e.RadicleID, body); err != nil { 314 + h.log.Error("ap reply: post comment", "series", series, "repo_pair", e.RepoPair, "err", err) 315 + } 264 316 } 265 317 266 318 // sendAccept replies to a Follow with a signed Accept, embedding the
+24
internal/activitypub/html.go
··· 1 + package activitypub 2 + 3 + import ( 4 + "html" 5 + "regexp" 6 + "strings" 7 + ) 8 + 9 + var ( 10 + tagRe = regexp.MustCompile(`<[^>]*>`) 11 + blockEndRe = regexp.MustCompile(`</(p|br|div)\s*>`) 12 + ) 13 + 14 + // stripHTML turns Mastodon's rich-text Note content (HTML, e.g. 15 + // "<p>hello <a href=...>@bot</a></p>") into plain text suitable for a 16 + // Forgejo/Radicle comment body. Not a sanitizer — this only ever displays 17 + // the result as inert markdown-adjacent plain text, never re-renders it as 18 + // HTML, so no XSS surface is introduced by skipping a full parser here. 19 + func stripHTML(s string) string { 20 + s = blockEndRe.ReplaceAllString(s, "\n") 21 + s = tagRe.ReplaceAllString(s, "") 22 + s = html.UnescapeString(s) 23 + return strings.TrimSpace(s) 24 + }
+21
internal/activitypub/outbox.go
··· 2 2 3 3 import ( 4 4 "strconv" 5 + "strings" 5 6 "time" 6 7 7 8 "graft/internal/state" ··· 46 47 // NoteURI returns the stable object URI for one activity_log entry's Note. 47 48 func NoteURI(host, series string, entryID int64) string { 48 49 return ActorURI(host, series) + "/notes/" + strconv.FormatInt(entryID, 10) 50 + } 51 + 52 + // ParseNoteURI is NoteURI's inverse: given a URI a reply's inReplyTo 53 + // claims to point at, extract the series and activity_log id it names — 54 + // used by the inbox to resolve a reply back to the item it's about. 55 + func ParseNoteURI(host, uri string) (series string, entryID int64, ok bool) { 56 + prefix := "https://" + host + "/actors/" 57 + rest := strings.TrimPrefix(uri, prefix) 58 + if rest == uri { 59 + return "", 0, false 60 + } 61 + parts := strings.Split(rest, "/") 62 + if len(parts) != 3 || parts[1] != "notes" { 63 + return "", 0, false 64 + } 65 + id, err := strconv.ParseInt(parts[2], 10, 64) 66 + if err != nil { 67 + return "", 0, false 68 + } 69 + return parts[0], id, true 49 70 } 50 71 51 72 // BuildNote turns one mirrored event into its Note representation.
+9
internal/forgejo/client.go
··· 144 144 } 145 145 return &pr, nil 146 146 } 147 + 148 + // CreateIssueComment adds a comment to an issue or pull request — Forgejo 149 + // (like Gitea) treats PR comments as issue comments under the same 150 + // endpoint, verified against a live PR on f1. 151 + func (c *Client) CreateIssueComment(index int64, body string) error { 152 + path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", c.owner, c.repo, index) 153 + req := map[string]string{"body": body} 154 + return c.do(http.MethodPost, path, req, nil) 155 + }
+17
internal/radicle/client.go
··· 134 134 State PatchState `json:"state"` 135 135 Author Actor `json:"author"` 136 136 Revisions []struct { 137 + ID string `json:"id"` // needed for `rad patch comment <revision-id>` 137 138 Description string `json:"description"` 138 139 OID string `json:"oid"` // head commit of the revision 139 140 Base string `json:"base"` // commit the patch is based on ··· 147 148 } 148 149 return patches, nil 149 150 } 151 + 152 + // CommentIssue adds a top-level comment to an issue (in reply to the issue 153 + // description itself, not to another comment) — verified against 154 + // `rad issue comment --help` on a live node. 155 + func (c *Client) CommentIssue(issueID, message string) error { 156 + _, err := c.rad("issue", "comment", issueID, "-m", message) 157 + return err 158 + } 159 + 160 + // CommentPatch adds a comment to a patch revision. Note this takes a 161 + // revision ID, not the patch's own ID — verified against 162 + // `rad patch comment --help` on a live node. 163 + func (c *Client) CommentPatch(revisionID, message string) error { 164 + _, err := c.rad("patch", "comment", revisionID, "-m", message) 165 + return err 166 + }
+15 -9
internal/state/store.go
··· 70 70 url TEXT NOT NULL DEFAULT '', 71 71 series TEXT NOT NULL DEFAULT '', 72 72 series_url TEXT NOT NULL DEFAULT '', 73 + forgejo_id INTEGER NOT NULL DEFAULT 0, 74 + radicle_id TEXT NOT NULL DEFAULT '', 73 75 occurred_at TEXT NOT NULL 74 76 ); 75 77 ··· 110 112 `ALTER TABLE activity_log ADD COLUMN url TEXT NOT NULL DEFAULT ''`, 111 113 `ALTER TABLE activity_log ADD COLUMN series TEXT NOT NULL DEFAULT ''`, 112 114 `ALTER TABLE activity_log ADD COLUMN series_url TEXT NOT NULL DEFAULT ''`, 115 + `ALTER TABLE activity_log ADD COLUMN forgejo_id INTEGER NOT NULL DEFAULT 0`, 116 + `ALTER TABLE activity_log ADD COLUMN radicle_id TEXT NOT NULL DEFAULT ''`, 113 117 } { 114 118 if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { 115 119 return err ··· 201 205 URL string // where to send someone who clicks this entry — the 202 206 // forge or Radicle page for the thing that now exists because of 203 207 // this sync; "" if none is known. 208 + ForgejoID int64 // issue/PR number on Forgejo, for kind "issue"/"patch"; 0 for "git" 209 + RadicleID string // issue/patch id on Radicle, for kind "issue"/"patch"; "" for "git" 204 210 } 205 211 206 212 // Direction values for Activity.Direction, kept as constants so callers ··· 215 221 // not once per sync pass. 216 222 func (s *Store) LogActivity(a Activity) error { 217 223 _, err := s.db.Exec(` 218 - INSERT INTO activity_log (repo_pair, kind, direction, summary, url, series, series_url, occurred_at) 219 - VALUES (?, ?, ?, ?, ?, ?, ?, ?) 220 - `, a.RepoPair, a.Kind, a.Direction, a.Summary, a.URL, a.Series, a.SeriesURL, time.Now().UTC().Format(time.RFC3339)) 224 + INSERT INTO activity_log (repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at) 225 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 226 + `, a.RepoPair, a.Kind, a.Direction, a.Summary, a.URL, a.Series, a.SeriesURL, a.ForgejoID, a.RadicleID, time.Now().UTC().Format(time.RFC3339)) 221 227 return err 222 228 } 223 229 ··· 231 237 // ActivitySince returns every activity entry at or after since, newest first. 232 238 func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) { 233 239 rows, err := s.db.Query(` 234 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 240 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 235 241 WHERE occurred_at >= ? 236 242 ORDER BY occurred_at DESC 237 243 `, since.UTC().Format(time.RFC3339)) ··· 246 252 // ActivityPub outbox. 247 253 func (s *Store) ActivityForSeries(series string, limit int) ([]ActivityEntry, error) { 248 254 rows, err := s.db.Query(` 249 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 255 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 250 256 WHERE series = ? 251 257 ORDER BY occurred_at DESC 252 258 LIMIT ? ··· 262 268 // item it describes. 263 269 func (s *Store) ActivityByID(id int64) (*ActivityEntry, error) { 264 270 row := s.db.QueryRow(` 265 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 271 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 266 272 WHERE id = ? 267 273 `, id) 268 274 var e ActivityEntry 269 275 var occurredAt string 270 - err := row.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &occurredAt) 276 + err := row.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &occurredAt) 271 277 if err == sql.ErrNoRows { 272 278 return nil, nil 273 279 } ··· 287 293 for rows.Next() { 288 294 var e ActivityEntry 289 295 var occurredAt string 290 - if err := rows.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &occurredAt); err != nil { 296 + 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 { 291 297 return nil, err 292 298 } 293 299 var err error ··· 424 430 // id > afterID, oldest first — the delivery order for ActivityPub. 425 431 func (s *Store) NewActivityForSeries(series string, afterID int64) ([]ActivityEntry, error) { 426 432 rows, err := s.db.Query(` 427 - SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 433 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log 428 434 WHERE series = ? AND id > ? 429 435 ORDER BY id ASC 430 436 `, series, afterID)
+4 -2
internal/sync/issues.go
··· 75 75 s.State.LogActivity(state.Activity{ 76 76 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 77 77 Kind: "issue", Direction: state.ForgejoToRadicle, Summary: fi.Title, 78 - URL: s.RadicleWebURL + "/issues/" + radicleID, 78 + URL: s.RadicleWebURL + "/issues/" + radicleID, 79 + ForgejoID: fi.Index, RadicleID: radicleID, 79 80 }) 80 81 return nil 81 82 } ··· 105 106 s.State.LogActivity(state.Activity{ 106 107 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 107 108 Kind: "issue", Direction: state.RadicleToForgejo, Summary: ri.Title, 108 - URL: fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, fi.Index), 109 + URL: fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, fi.Index), 110 + ForgejoID: fi.Index, RadicleID: ri.ID, 109 111 }) 110 112 return nil 111 113 }
+4 -2
internal/sync/patches.go
··· 121 121 s.State.LogActivity(state.Activity{ 122 122 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 123 123 Kind: "patch", Direction: state.ForgejoToRadicle, Summary: pr.Title, 124 - URL: s.RadicleWebURL + "/patches/" + m[1], 124 + URL: s.RadicleWebURL + "/patches/" + m[1], 125 + ForgejoID: pr.Index, RadicleID: m[1], 125 126 }) 126 127 return nil 127 128 } ··· 166 167 s.State.LogActivity(state.Activity{ 167 168 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL, 168 169 Kind: "patch", Direction: state.RadicleToForgejo, Summary: p.Title, 169 - URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index), 170 + URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index), 171 + ForgejoID: pr.Index, RadicleID: p.ID, 170 172 }) 171 173 return nil 172 174 }
+33
internal/sync/syncer.go
··· 1 1 package sync 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log/slog" 6 7 "net/url" ··· 117 118 return rs.pair.Series 118 119 } 119 120 return rs.pair.Name 121 + } 122 + 123 + // CommentOnItem posts a reply (from the ActivityPub comment bridge, see 124 + // internal/activitypub) onto the underlying Forgejo issue/PR and Radicle 125 + // issue/patch this pair mirrors. For a patch, radicleID is used as the 126 + // revision to comment on — correct for the common single-revision case, 127 + // since a patch's own id equals its first revision's id; a patch that has 128 + // since been updated with further revisions may have the comment attach 129 + // to an older revision instead of the latest one, a known scoped 130 + // limitation rather than a bug to chase down for v2. 131 + func (rs *RepoSyncer) CommentOnItem(kind string, forgejoID int64, radicleID, body string) error { 132 + var errs []error 133 + if forgejoID != 0 { 134 + if err := rs.forgejo.CreateIssueComment(forgejoID, body); err != nil { 135 + errs = append(errs, fmt.Errorf("forgejo: %w", err)) 136 + } 137 + } 138 + if radicleID != "" { 139 + var err error 140 + switch kind { 141 + case "issue": 142 + err = rs.radicle.CommentIssue(radicleID, body) 143 + case "patch": 144 + err = rs.radicle.CommentPatch(radicleID, body) 145 + default: 146 + err = fmt.Errorf("cannot comment on kind %q", kind) 147 + } 148 + if err != nil { 149 + errs = append(errs, fmt.Errorf("radicle: %w", err)) 150 + } 151 + } 152 + return errors.Join(errs...) 120 153 } 121 154 122 155 // Run executes every enabled scope once, logging and continuing past