Federated via graft
7.6 kB
218 lines
1// Package radicle talks to one local Radicle node: reads go through
2// radicle-httpd's JSON API (schema confirmed against a live v0.28.0 node —
3// see the field comments below), writes go through the "rad" CLI since
4// radicle-httpd exposes no write endpoints.
5package radicle
6
7import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "fmt"
12 "io"
13 "net/http"
14 "os"
15 "os/exec"
16 "regexp"
17 "time"
18)
19
20// execTimeout bounds every "rad" CLI subprocess — see the matching
21// constant and comment in internal/sync/git.go for why this matters: a
22// hung subprocess would otherwise block the whole sync loop.
23const execTimeout = 5 * time.Minute
24
25// maxResponseBody caps how much of a radicle-httpd response this client
26// reads into memory.
27const maxResponseBody = 4 << 20 // 4 MiB — patch/issue listings can be large
28
29// Client operates on one repository (identified by its RID) tracked by one
30// local Radicle node.
31type Client struct {
32 httpdBaseURL string // e.g. https://r1.cyberwild.org, used for reads
33 rid string
34 radHome string // RAD_HOME for the "rad" CLI, used for writes
35 radBin string // path to the rad binary
36 http *http.Client
37}
38
39func New(httpdBaseURL, rid, radHome, radBin string) *Client {
40 return &Client{
41 httpdBaseURL: httpdBaseURL,
42 rid: rid,
43 radHome: radHome,
44 radBin: radBin,
45 http: &http.Client{Timeout: 30 * time.Second},
46 }
47}
48
49func (c *Client) getJSON(path string, out any) error {
50 resp, err := c.http.Get(c.httpdBaseURL + "/api/v1/repos/" + c.rid + path)
51 if err != nil {
52 return fmt.Errorf("GET %s: %w", path, err)
53 }
54 defer resp.Body.Close()
55 if resp.StatusCode >= 300 {
56 return fmt.Errorf("GET %s: HTTP %d", path, resp.StatusCode)
57 }
58 return json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(out)
59}
60
61// rad runs the CLI against this client's repository and RAD_HOME, returning
62// combined stdout+stderr for logging on error.
63func (c *Client) rad(args ...string) (string, error) {
64 args = append(args, "--repo", c.rid)
65 ctx, cancel := context.WithTimeout(context.Background(), execTimeout)
66 defer cancel()
67 cmd := exec.CommandContext(ctx, c.radBin, args...)
68 cmd.Env = append(os.Environ(), "RAD_HOME="+c.radHome)
69 var out bytes.Buffer
70 cmd.Stdout = &out
71 cmd.Stderr = &out
72 if err := cmd.Run(); err != nil {
73 return out.String(), fmt.Errorf("rad %v: %w: %s", args, err, out.String())
74 }
75 return out.String(), nil
76}
77
78// Actor identifies who authored an issue, comment, or patch revision.
79type Actor struct {
80 ID string `json:"id"` // did:key:...
81 Alias string `json:"alias"`
82}
83
84// IssueState mirrors radicle-httpd's issue state object.
85type IssueState struct {
86 Status string `json:"status"` // "open" or "closed"
87}
88
89// DiscussionItem is one entry in an issue's or patch revision's
90// discussion array — verified against GET /api/v1/repos/:rid/issues on a
91// live node: discussion[0] is always the issue's own opening description
92// (its id equals the issue's own id), every entry after that is a real
93// comment. replyTo is a comment id or null; not currently used, kept for
94// when threaded display is worth building.
95type DiscussionItem struct {
96 ID string `json:"id"`
97 Author Actor `json:"author"`
98 Body string `json:"body"`
99 Timestamp int64 `json:"timestamp"` // unix seconds
100 ReplyTo string `json:"replyTo"`
101}
102
103// Issue is the subset of radicle-httpd's issue JSON the daemon needs.
104// Verified against GET /api/v1/repos/:rid/issues?status=all on httpd 0.28.0.
105type Issue struct {
106 ID string `json:"id"`
107 Title string `json:"title"`
108 State IssueState `json:"state"`
109 Author Actor `json:"author"`
110 Discussion []DiscussionItem `json:"discussion"`
111}
112
113// Body returns the issue's opening comment, if any.
114func (i Issue) Body() string {
115 if len(i.Discussion) == 0 {
116 return ""
117 }
118 return i.Discussion[0].Body
119}
120
121// Comments returns the issue's real comments — everything after the
122// opening description in Discussion.
123func (i Issue) Comments() []DiscussionItem {
124 if len(i.Discussion) <= 1 {
125 return nil
126 }
127 return i.Discussion[1:]
128}
129
130func (c *Client) ListIssues() ([]Issue, error) {
131 var issues []Issue
132 if err := c.getJSON("/issues?status=all", &issues); err != nil {
133 return nil, err
134 }
135 return issues, nil
136}
137
138// issueIDRe matches the "Issue <id>" line in rad's non-quiet issue-open
139// output box. --quiet was tried first and rejected: it suppresses the id
140// entirely (verified against a live node), printing only the sync summary,
141// which silently corrupted the stored mapping and caused every issue to be
142// re-mirrored on each pass.
143var issueIDRe = regexp.MustCompile(`Issue\s+([0-9a-f]{40})`)
144
145// OpenIssue creates a new issue via the CLI (no write API exists on httpd).
146func (c *Client) OpenIssue(title, description string) (string, error) {
147 out, err := c.rad("issue", "open", "-t", title, "-d", description)
148 if err != nil {
149 return "", err
150 }
151 m := issueIDRe.FindStringSubmatch(out)
152 if m == nil {
153 return "", fmt.Errorf("rad issue open: could not parse issue id from output: %q", out)
154 }
155 return m[1], nil
156}
157
158// PatchState mirrors radicle-httpd's patch state object.
159type PatchState struct {
160 Status string `json:"status"` // "open", "draft", "archived", "merged"
161}
162
163// Patch is the subset of radicle-httpd's patch JSON the daemon needs.
164// Verified against GET /api/v1/repos/:rid/patches?status=all on httpd 0.28.0.
165type Patch struct {
166 ID string `json:"id"`
167 Title string `json:"title"`
168 State PatchState `json:"state"`
169 Author Actor `json:"author"`
170 Revisions []struct {
171 ID string `json:"id"` // needed for `rad patch comment <revision-id>`
172 Description string `json:"description"`
173 OID string `json:"oid"` // head commit of the revision
174 Base string `json:"base"` // commit the patch is based on
175 // Discussion follows the same shape as an issue's, by analogy
176 // with radicle-httpd's issue endpoint — NOT independently
177 // verified against a live node the way the issue schema above
178 // is (no patch with comments existed to test against at the
179 // time this was written). Comment sync degrades to "no radicle
180 // comments seen" rather than erroring if this turns out wrong;
181 // verify against a real patch before trusting it further.
182 Discussion []DiscussionItem `json:"discussion"`
183 } `json:"revisions"`
184}
185
186// Comments returns a patch's comments, drawn from its first revision —
187// the same scoping CommentPatch already documents (a patch's own id
188// equals its first revision's id in the common single-revision case).
189func (p Patch) Comments() []DiscussionItem {
190 if len(p.Revisions) == 0 {
191 return nil
192 }
193 return p.Revisions[0].Discussion
194}
195
196func (c *Client) ListPatches() ([]Patch, error) {
197 var patches []Patch
198 if err := c.getJSON("/patches?status=all", &patches); err != nil {
199 return nil, err
200 }
201 return patches, nil
202}
203
204// CommentIssue adds a top-level comment to an issue (in reply to the issue
205// description itself, not to another comment) — verified against
206// `rad issue comment --help` on a live node.
207func (c *Client) CommentIssue(issueID, message string) error {
208 _, err := c.rad("issue", "comment", issueID, "-m", message)
209 return err
210}
211
212// CommentPatch adds a comment to a patch revision. Note this takes a
213// revision ID, not the patch's own ID — verified against
214// `rad patch comment --help` on a live node.
215func (c *Client) CommentPatch(revisionID, message string) error {
216 _, err := c.rad("patch", "comment", revisionID, "-m", message)
217 return err
218}