Federated via graft
6.2 kB
190 lines
1package sync
2
3import (
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).
14const (
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.
28var mirrorPrefixes = []string{"**via Forgejo:**", "**via Radicle:**", "**via Fediverse,", "**via Bluesky,"}
29
30func 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.
42const maxCommentSummary = 200
43
44func 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.
57func (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.
126func (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}