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