Federated via graft
5.9 kB
179 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// Issue is the subset of radicle-httpd's issue JSON the daemon needs.
90// Verified against GET /api/v1/repos/:rid/issues?status=all on httpd 0.28.0.
91type Issue struct {
92 ID string `json:"id"`
93 Title string `json:"title"`
94 State IssueState `json:"state"`
95 Author Actor `json:"author"`
96 Discussion []struct {
97 Body string `json:"body"`
98 } `json:"discussion"`
99}
100
101// Body returns the issue's opening comment, if any.
102func (i Issue) Body() string {
103 if len(i.Discussion) == 0 {
104 return ""
105 }
106 return i.Discussion[0].Body
107}
108
109func (c *Client) ListIssues() ([]Issue, error) {
110 var issues []Issue
111 if err := c.getJSON("/issues?status=all", &issues); err != nil {
112 return nil, err
113 }
114 return issues, nil
115}
116
117// issueIDRe matches the "Issue <id>" line in rad's non-quiet issue-open
118// output box. --quiet was tried first and rejected: it suppresses the id
119// entirely (verified against a live node), printing only the sync summary,
120// which silently corrupted the stored mapping and caused every issue to be
121// re-mirrored on each pass.
122var issueIDRe = regexp.MustCompile(`Issue\s+([0-9a-f]{40})`)
123
124// OpenIssue creates a new issue via the CLI (no write API exists on httpd).
125func (c *Client) OpenIssue(title, description string) (string, error) {
126 out, err := c.rad("issue", "open", "-t", title, "-d", description)
127 if err != nil {
128 return "", err
129 }
130 m := issueIDRe.FindStringSubmatch(out)
131 if m == nil {
132 return "", fmt.Errorf("rad issue open: could not parse issue id from output: %q", out)
133 }
134 return m[1], nil
135}
136
137// PatchState mirrors radicle-httpd's patch state object.
138type PatchState struct {
139 Status string `json:"status"` // "open", "draft", "archived", "merged"
140}
141
142// Patch is the subset of radicle-httpd's patch JSON the daemon needs.
143// Verified against GET /api/v1/repos/:rid/patches?status=all on httpd 0.28.0.
144type Patch struct {
145 ID string `json:"id"`
146 Title string `json:"title"`
147 State PatchState `json:"state"`
148 Author Actor `json:"author"`
149 Revisions []struct {
150 ID string `json:"id"` // needed for `rad patch comment <revision-id>`
151 Description string `json:"description"`
152 OID string `json:"oid"` // head commit of the revision
153 Base string `json:"base"` // commit the patch is based on
154 } `json:"revisions"`
155}
156
157func (c *Client) ListPatches() ([]Patch, error) {
158 var patches []Patch
159 if err := c.getJSON("/patches?status=all", &patches); err != nil {
160 return nil, err
161 }
162 return patches, nil
163}
164
165// CommentIssue adds a top-level comment to an issue (in reply to the issue
166// description itself, not to another comment) — verified against
167// `rad issue comment --help` on a live node.
168func (c *Client) CommentIssue(issueID, message string) error {
169 _, err := c.rad("issue", "comment", issueID, "-m", message)
170 return err
171}
172
173// CommentPatch adds a comment to a patch revision. Note this takes a
174// revision ID, not the patch's own ID — verified against
175// `rad patch comment --help` on a live node.
176func (c *Client) CommentPatch(revisionID, message string) error {
177 _, err := c.rad("patch", "comment", revisionID, "-m", message)
178 return err
179}