Federated via graft
5.3 kB
166 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 "encoding/json"
10 "fmt"
11 "net/http"
12 "os"
13 "os/exec"
14 "regexp"
15 "time"
16)
17
18// Client operates on one repository (identified by its RID) tracked by one
19// local Radicle node.
20type Client struct {
21 httpdBaseURL string // e.g. https://r1.cyberwild.org, used for reads
22 rid string
23 radHome string // RAD_HOME for the "rad" CLI, used for writes
24 radBin string // path to the rad binary
25 http *http.Client
26}
27
28func New(httpdBaseURL, rid, radHome, radBin string) *Client {
29 return &Client{
30 httpdBaseURL: httpdBaseURL,
31 rid: rid,
32 radHome: radHome,
33 radBin: radBin,
34 http: &http.Client{Timeout: 30 * time.Second},
35 }
36}
37
38func (c *Client) getJSON(path string, out any) error {
39 resp, err := c.http.Get(c.httpdBaseURL + "/api/v1/repos/" + c.rid + path)
40 if err != nil {
41 return fmt.Errorf("GET %s: %w", path, err)
42 }
43 defer resp.Body.Close()
44 if resp.StatusCode >= 300 {
45 return fmt.Errorf("GET %s: HTTP %d", path, resp.StatusCode)
46 }
47 return json.NewDecoder(resp.Body).Decode(out)
48}
49
50// rad runs the CLI against this client's repository and RAD_HOME, returning
51// combined stdout+stderr for logging on error.
52func (c *Client) rad(args ...string) (string, error) {
53 args = append(args, "--repo", c.rid)
54 cmd := exec.Command(c.radBin, args...)
55 cmd.Env = append(os.Environ(), "RAD_HOME="+c.radHome)
56 var out bytes.Buffer
57 cmd.Stdout = &out
58 cmd.Stderr = &out
59 if err := cmd.Run(); err != nil {
60 return out.String(), fmt.Errorf("rad %v: %w: %s", args, err, out.String())
61 }
62 return out.String(), nil
63}
64
65// Actor identifies who authored an issue, comment, or patch revision.
66type Actor struct {
67 ID string `json:"id"` // did:key:...
68 Alias string `json:"alias"`
69}
70
71// IssueState mirrors radicle-httpd's issue state object.
72type IssueState struct {
73 Status string `json:"status"` // "open" or "closed"
74}
75
76// Issue is the subset of radicle-httpd's issue JSON the daemon needs.
77// Verified against GET /api/v1/repos/:rid/issues?status=all on httpd 0.28.0.
78type Issue struct {
79 ID string `json:"id"`
80 Title string `json:"title"`
81 State IssueState `json:"state"`
82 Author Actor `json:"author"`
83 Discussion []struct {
84 Body string `json:"body"`
85 } `json:"discussion"`
86}
87
88// Body returns the issue's opening comment, if any.
89func (i Issue) Body() string {
90 if len(i.Discussion) == 0 {
91 return ""
92 }
93 return i.Discussion[0].Body
94}
95
96func (c *Client) ListIssues() ([]Issue, error) {
97 var issues []Issue
98 if err := c.getJSON("/issues?status=all", &issues); err != nil {
99 return nil, err
100 }
101 return issues, nil
102}
103
104// issueIDRe matches the "Issue <id>" line in rad's non-quiet issue-open
105// output box. --quiet was tried first and rejected: it suppresses the id
106// entirely (verified against a live node), printing only the sync summary,
107// which silently corrupted the stored mapping and caused every issue to be
108// re-mirrored on each pass.
109var issueIDRe = regexp.MustCompile(`Issue\s+([0-9a-f]{40})`)
110
111// OpenIssue creates a new issue via the CLI (no write API exists on httpd).
112func (c *Client) OpenIssue(title, description string) (string, error) {
113 out, err := c.rad("issue", "open", "-t", title, "-d", description)
114 if err != nil {
115 return "", err
116 }
117 m := issueIDRe.FindStringSubmatch(out)
118 if m == nil {
119 return "", fmt.Errorf("rad issue open: could not parse issue id from output: %q", out)
120 }
121 return m[1], nil
122}
123
124// PatchState mirrors radicle-httpd's patch state object.
125type PatchState struct {
126 Status string `json:"status"` // "open", "draft", "archived", "merged"
127}
128
129// Patch is the subset of radicle-httpd's patch JSON the daemon needs.
130// Verified against GET /api/v1/repos/:rid/patches?status=all on httpd 0.28.0.
131type Patch struct {
132 ID string `json:"id"`
133 Title string `json:"title"`
134 State PatchState `json:"state"`
135 Author Actor `json:"author"`
136 Revisions []struct {
137 ID string `json:"id"` // needed for `rad patch comment <revision-id>`
138 Description string `json:"description"`
139 OID string `json:"oid"` // head commit of the revision
140 Base string `json:"base"` // commit the patch is based on
141 } `json:"revisions"`
142}
143
144func (c *Client) ListPatches() ([]Patch, error) {
145 var patches []Patch
146 if err := c.getJSON("/patches?status=all", &patches); err != nil {
147 return nil, err
148 }
149 return patches, nil
150}
151
152// CommentIssue adds a top-level comment to an issue (in reply to the issue
153// description itself, not to another comment) — verified against
154// `rad issue comment --help` on a live node.
155func (c *Client) CommentIssue(issueID, message string) error {
156 _, err := c.rad("issue", "comment", issueID, "-m", message)
157 return err
158}
159
160// CommentPatch adds a comment to a patch revision. Note this takes a
161// revision ID, not the patch's own ID — verified against
162// `rad patch comment --help` on a live node.
163func (c *Client) CommentPatch(revisionID, message string) error {
164 _, err := c.rad("patch", "comment", revisionID, "-m", message)
165 return err
166}