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// 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}