Federated via graft
3.9 kB
131 lines
1package sync
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7
8 "graft/internal/forgejo"
9 "graft/internal/radicle"
10 "graft/internal/state"
11)
12
13// A marker prepended to mirrored items' bodies so a human browsing either
14// side can tell where an issue/patch actually originated.
15const mirrorNotice = "\n\n---\n_Mirrored automatically, do not edit here — edit at the source instead._"
16
17// IssueSyncer mirrors issues between one Forgejo repository and one Radicle
18// repository, create-only (see package doc for what that means).
19type IssueSyncer struct {
20 RepoPair string
21 Forgejo *forgejo.Client
22 Radicle *radicle.Client
23 State *state.Store
24 ForgejoWebURL string // https://host/owner/repo, for links in the UI
25 RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI
26 Series string // dashboard row this pair's activity groups under
27 SeriesURL string // where clicking that row's name goes
28}
29
30func (s *IssueSyncer) Sync() error {
31 forgejoIssues, err := s.Forgejo.ListIssues()
32 if err != nil {
33 return fmt.Errorf("list forgejo issues: %w", err)
34 }
35 radicleIssues, err := s.Radicle.ListIssues()
36 if err != nil {
37 return fmt.Errorf("list radicle issues: %w", err)
38 }
39 radicleByID := make(map[string]radicle.Issue, len(radicleIssues))
40 for _, ri := range radicleIssues {
41 radicleByID[ri.ID] = ri
42 }
43
44 for _, fi := range forgejoIssues {
45 if err := s.mirrorForgejoToRadicle(fi, radicleByID); err != nil {
46 return fmt.Errorf("mirror forgejo issue #%d: %w", fi.Index, err)
47 }
48 }
49 for _, ri := range radicleIssues {
50 if err := s.mirrorRadicleToForgejo(ri); err != nil {
51 return fmt.Errorf("mirror radicle issue %s: %w", ri.ID, err)
52 }
53 }
54 return nil
55}
56
57func (s *IssueSyncer) mirrorForgejoToRadicle(fi forgejo.Issue, radicleByID map[string]radicle.Issue) error {
58 existing, err := s.State.FindByForgejoID(s.RepoPair, "issue", fi.Index)
59 if err != nil {
60 return err
61 }
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 }
68 return nil // already mirrored; edits not yet synced (see package doc)
69 }
70
71 radicleID, err := s.Radicle.OpenIssue(fi.Title, fi.Body+mirrorNotice)
72 if err != nil {
73 return fmt.Errorf("open radicle issue: %w", err)
74 }
75
76 if err := s.State.Upsert(s.RepoPair, state.ItemMapping{
77 Kind: "issue",
78 ForgejoID: fi.Index,
79 RadicleID: radicleID,
80 ContentHash: hashText(fi.Title, fi.Body),
81 }); err != nil {
82 return err
83 }
84 s.State.LogActivity(state.Activity{
85 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
86 Kind: "issue", Direction: state.ForgejoToRadicle, Summary: fi.Title,
87 URL: s.RadicleWebURL + "/issues/" + radicleID,
88 ForgejoID: fi.Index, RadicleID: radicleID,
89 })
90 return nil
91}
92
93func (s *IssueSyncer) mirrorRadicleToForgejo(ri radicle.Issue) error {
94 existing, err := s.State.FindByRadicleID(s.RepoPair, "issue", ri.ID)
95 if err != nil {
96 return err
97 }
98 if existing != nil {
99 return nil
100 }
101
102 fi, err := s.Forgejo.CreateIssue(ri.Title, ri.Body()+mirrorNotice)
103 if err != nil {
104 return fmt.Errorf("create forgejo issue: %w", err)
105 }
106
107 if err := s.State.Upsert(s.RepoPair, state.ItemMapping{
108 Kind: "issue",
109 ForgejoID: fi.Index,
110 RadicleID: ri.ID,
111 ContentHash: hashText(ri.Title, ri.Body()),
112 }); err != nil {
113 return err
114 }
115 s.State.LogActivity(state.Activity{
116 RepoPair: s.RepoPair, Series: s.Series, SeriesURL: s.SeriesURL,
117 Kind: "issue", Direction: state.RadicleToForgejo, Summary: ri.Title,
118 URL: fmt.Sprintf("%s/issues/%d", s.ForgejoWebURL, fi.Index),
119 ForgejoID: fi.Index, RadicleID: ri.ID,
120 })
121 return nil
122}
123
124func hashText(parts ...string) string {
125 h := sha256.New()
126 for _, p := range parts {
127 h.Write([]byte(p))
128 h.Write([]byte{0})
129 }
130 return hex.EncodeToString(h.Sum(nil))
131}