Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

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}