Federated via graft
0

Configure Feed

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

graft-source / internal / sync / comments.go
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}