Federated via graft
5.6 kB
172 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// maxCommentSummary bounds how much of a comment's body lands in the
22// dashboard's summary column — comments run much longer than a commit
23// subject line or an issue title.
24const maxCommentSummary = 200
25
26func truncateComment(body string) string {
27 body = strings.TrimSpace(body)
28 r := []rune(body)
29 if len(r) <= maxCommentSummary {
30 return body
31 }
32 return string(r[:maxCommentSummary-1]) + "…"
33}
34
35// syncIssueComments cross-mirrors an issue's comments between Forgejo and
36// Radicle. ri is the already-fetched Radicle issue (its Discussion is
37// used directly — no extra API call), forgejoIndex identifies the same
38// issue on Forgejo.
39func (s *IssueSyncer) syncIssueComments(forgejoIndex int64, ri radicle.Issue) error {
40 fcs, err := s.Forgejo.ListIssueComments(forgejoIndex)
41 if err != nil {
42 return fmt.Errorf("list forgejo comments: %w", err)
43 }
44
45 for _, fc := range fcs {
46 if state.IsMirroredComment(fc.Body) {
47 continue
48 }
49 hash := hashText(fc.Body)
50 seen, err := s.State.CommentSeen(s.RepoPair, "issue", ri.ID, hash)
51 if err != nil {
52 return err
53 }
54 if seen {
55 continue
56 }
57 if err := s.Radicle.CommentIssue(ri.ID, state.MarkMirrored("**via Forgejo:**\n\n"+fc.Body)); err != nil {
58 return fmt.Errorf("mirror comment to radicle: %w", err)
59 }
60 if err := s.State.MarkCommentSeen(s.RepoPair, "issue", ri.ID, hash); err != nil {
61 return err
62 }
63 s.State.LogActivity(state.Activity{
64 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
65 Kind: "comment", Direction: state.ForgejoToRadicle, Origin: originForgejo,
66 Summary: truncateComment(fc.Body),
67 URL: s.RadicleWebURL + "/issues/" + ri.ID,
68 ForgejoID: forgejoIndex, RadicleID: ri.ID,
69 })
70 }
71
72 for _, item := range ri.Comments() {
73 if state.IsMirroredComment(item.Body) {
74 continue
75 }
76 hash := hashText(item.Body)
77 seen, err := s.State.CommentSeen(s.RepoPair, "issue", ri.ID, hash)
78 if err != nil {
79 return err
80 }
81 if seen {
82 continue
83 }
84 if err := s.Forgejo.CreateIssueComment(forgejoIndex, state.MarkMirrored("**via Radicle:**\n\n"+item.Body)); err != nil {
85 return fmt.Errorf("mirror comment to forgejo: %w", err)
86 }
87 if err := s.State.MarkCommentSeen(s.RepoPair, "issue", ri.ID, hash); err != nil {
88 return err
89 }
90 s.State.LogActivity(state.Activity{
91 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
92 Kind: "comment", Direction: state.RadicleToForgejo, Origin: originRadicle,
93 Summary: truncateComment(item.Body),
94 URL: fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, forgejoIndex),
95 ForgejoID: forgejoIndex, RadicleID: ri.ID,
96 })
97 }
98 return nil
99}
100
101// syncPatchComments is syncIssueComments' patch equivalent. Radicle-side
102// comments come from the patch's first revision (Patch.Comments; see its
103// doc comment — the same single-revision scoping CommentPatch already
104// has) and are NOT independently verified against a live node the way
105// issue comments are (see the Discussion field's doc comment in
106// radicle/client.go). Degrades to silently seeing no Radicle comments
107// if that schema assumption is wrong, rather than erroring.
108func (s *PatchSyncer) syncPatchComments(forgejoIndex int64, rp radicle.Patch) error {
109 if len(rp.Revisions) == 0 {
110 return nil
111 }
112 revisionID := rp.Revisions[0].ID
113
114 fcs, err := s.Forgejo.ListIssueComments(forgejoIndex)
115 if err != nil {
116 return fmt.Errorf("list forgejo comments: %w", err)
117 }
118 for _, fc := range fcs {
119 if state.IsMirroredComment(fc.Body) {
120 continue
121 }
122 hash := hashText(fc.Body)
123 seen, err := s.State.CommentSeen(s.RepoPair, "patch", rp.ID, hash)
124 if err != nil {
125 return err
126 }
127 if seen {
128 continue
129 }
130 if err := s.Radicle.CommentPatch(revisionID, state.MarkMirrored("**via Forgejo:**\n\n"+fc.Body)); err != nil {
131 return fmt.Errorf("mirror comment to radicle: %w", err)
132 }
133 if err := s.State.MarkCommentSeen(s.RepoPair, "patch", rp.ID, hash); err != nil {
134 return err
135 }
136 s.State.LogActivity(state.Activity{
137 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
138 Kind: "comment", Direction: state.ForgejoToRadicle, Origin: originForgejo,
139 Summary: truncateComment(fc.Body),
140 URL: s.RadicleWebURL + "/patches/" + rp.ID,
141 ForgejoID: forgejoIndex, RadicleID: rp.ID,
142 })
143 }
144
145 for _, item := range rp.Comments() {
146 if state.IsMirroredComment(item.Body) {
147 continue
148 }
149 hash := hashText(item.Body)
150 seen, err := s.State.CommentSeen(s.RepoPair, "patch", rp.ID, hash)
151 if err != nil {
152 return err
153 }
154 if seen {
155 continue
156 }
157 if err := s.Forgejo.CreateIssueComment(forgejoIndex, state.MarkMirrored("**via Radicle:**\n\n"+item.Body)); err != nil {
158 return fmt.Errorf("mirror comment to forgejo: %w", err)
159 }
160 if err := s.State.MarkCommentSeen(s.RepoPair, "patch", rp.ID, hash); err != nil {
161 return err
162 }
163 s.State.LogActivity(state.Activity{
164 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
165 Kind: "comment", Direction: state.RadicleToForgejo, Origin: originRadicle,
166 Summary: truncateComment(item.Body),
167 URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, forgejoIndex),
168 ForgejoID: forgejoIndex, RadicleID: rp.ID,
169 })
170 }
171 return nil
172}