Federated via graft
7.2 kB
239 lines
1// Package forgejo is a minimal client for the subset of the Forgejo REST
2// API the sync daemon needs: reading/creating issues and pull requests,
3// and resolving a repository's clone URL and default branch.
4package forgejo
5
6import (
7 "bytes"
8 "encoding/base64"
9 "encoding/json"
10 "fmt"
11 "io"
12 "net/http"
13 "strings"
14 "time"
15)
16
17// maxResponseBody caps how much of a Forgejo response this client reads
18// into memory.
19const maxResponseBody = 4 << 20 // 4 MiB
20
21// maxLoggedBody caps how much of an error response body gets embedded in
22// an error message (and from there, into logs) — an upstream server's
23// response is content we don't control, so it shouldn't get an unbounded
24// write into our own logging.
25const maxLoggedBody = 500
26
27// Client talks to one Forgejo instance on behalf of one repository.
28type Client struct {
29 baseURL string
30 owner string
31 repo string
32 token string
33 http *http.Client
34}
35
36func New(baseURL, owner, repo, token string) *Client {
37 return &Client{
38 baseURL: strings.TrimRight(baseURL, "/"),
39 owner: owner,
40 repo: repo,
41 token: token,
42 http: &http.Client{Timeout: 30 * time.Second},
43 }
44}
45
46func (c *Client) do(method, path string, body any, out any) error {
47 var reqBody io.Reader
48 if body != nil {
49 b, err := json.Marshal(body)
50 if err != nil {
51 return fmt.Errorf("encode request: %w", err)
52 }
53 reqBody = bytes.NewReader(b)
54 }
55
56 url := c.baseURL + "/api/v1" + path
57 req, err := http.NewRequest(method, url, reqBody)
58 if err != nil {
59 return fmt.Errorf("build request: %w", err)
60 }
61 req.Header.Set("Authorization", "token "+c.token)
62 req.Header.Set("Content-Type", "application/json")
63
64 resp, err := c.http.Do(req)
65 if err != nil {
66 return fmt.Errorf("%s %s: %w", method, path, err)
67 }
68 defer resp.Body.Close()
69
70 if resp.StatusCode >= 300 {
71 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
72 return fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(respBody))
73 }
74
75 if out != nil {
76 if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(out); err != nil {
77 return fmt.Errorf("decode response for %s %s: %w", method, path, err)
78 }
79 }
80 return nil
81}
82
83func truncate(b []byte) string {
84 if len(b) <= maxLoggedBody {
85 return string(b)
86 }
87 return string(b[:maxLoggedBody]) + "... (truncated)"
88}
89
90// Repository is the subset of Forgejo's repository object the daemon uses.
91type Repository struct {
92 CloneURL string `json:"clone_url"`
93 DefaultBranch string `json:"default_branch"`
94}
95
96func (c *Client) GetRepository() (*Repository, error) {
97 var repo Repository
98 path := fmt.Sprintf("/repos/%s/%s", c.owner, c.repo)
99 if err := c.do(http.MethodGet, path, nil, &repo); err != nil {
100 return nil, err
101 }
102 return &repo, nil
103}
104
105// Issue is the subset of Forgejo's issue object the daemon reads and writes.
106type Issue struct {
107 Index int64 `json:"number"`
108 Title string `json:"title"`
109 Body string `json:"body"`
110 State string `json:"state"`
111}
112
113func (c *Client) ListIssues() ([]Issue, error) {
114 var issues []Issue
115 path := fmt.Sprintf("/repos/%s/%s/issues?type=issues&state=all&limit=50", c.owner, c.repo)
116 if err := c.do(http.MethodGet, path, nil, &issues); err != nil {
117 return nil, err
118 }
119 return issues, nil
120}
121
122func (c *Client) CreateIssue(title, body string) (*Issue, error) {
123 var issue Issue
124 path := fmt.Sprintf("/repos/%s/%s/issues", c.owner, c.repo)
125 req := map[string]string{"title": title, "body": body}
126 if err := c.do(http.MethodPost, path, req, &issue); err != nil {
127 return nil, err
128 }
129 return &issue, nil
130}
131
132// PullRequest is the subset of Forgejo's pull request object the daemon
133// reads and writes.
134type PullRequest struct {
135 Index int64 `json:"number"`
136 Title string `json:"title"`
137 Body string `json:"body"`
138 State string `json:"state"`
139 Head Branch `json:"head"`
140 Base Branch `json:"base"`
141}
142
143type Branch struct {
144 Ref string `json:"ref"`
145}
146
147func (c *Client) ListPullRequests() ([]PullRequest, error) {
148 var prs []PullRequest
149 path := fmt.Sprintf("/repos/%s/%s/pulls?state=all&limit=50", c.owner, c.repo)
150 if err := c.do(http.MethodGet, path, nil, &prs); err != nil {
151 return nil, err
152 }
153 return prs, nil
154}
155
156func (c *Client) CreatePullRequest(title, body, head, base string) (*PullRequest, error) {
157 var pr PullRequest
158 path := fmt.Sprintf("/repos/%s/%s/pulls", c.owner, c.repo)
159 req := map[string]string{"title": title, "body": body, "head": head, "base": base}
160 if err := c.do(http.MethodPost, path, req, &pr); err != nil {
161 return nil, err
162 }
163 return &pr, nil
164}
165
166// CreateIssueComment adds a comment to an issue or pull request — Forgejo
167// (like Gitea) treats PR comments as issue comments under the same
168// endpoint, verified against a live PR on f1.
169func (c *Client) CreateIssueComment(index int64, body string) error {
170 path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", c.owner, c.repo, index)
171 req := map[string]string{"body": body}
172 return c.do(http.MethodPost, path, req, nil)
173}
174
175// Comment is the subset of Forgejo's issue-comment object the daemon
176// reads. Verified against a live GET on f2.
177type Comment struct {
178 ID int64 `json:"id"`
179 Body string `json:"body"`
180 CreatedAt string `json:"created_at"`
181}
182
183// ListIssueComments lists every comment on an issue or pull request —
184// same issues-endpoint-for-PRs-too caveat as CreateIssueComment.
185func (c *Client) ListIssueComments(index int64) ([]Comment, error) {
186 var comments []Comment
187 path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", c.owner, c.repo, index)
188 if err := c.do(http.MethodGet, path, nil, &comments); err != nil {
189 return nil, err
190 }
191 return comments, nil
192}
193
194// contentEntry is the subset of Forgejo's contents API response this
195// client reads, shared by both directory listings and single-file fetches.
196type contentEntry struct {
197 Name string `json:"name"`
198 Type string `json:"type"` // "file" or "dir"
199 Content string `json:"content,omitempty"`
200 Encoding string `json:"encoding,omitempty"`
201}
202
203// ListWorkflowFiles lists file names directly under .forgejo/workflows/.
204// Returns an empty slice (not an error) if the directory doesn't exist —
205// most repos have no workflows at all, which is a normal, expected state.
206func (c *Client) ListWorkflowFiles() ([]string, error) {
207 var entries []contentEntry
208 path := fmt.Sprintf("/repos/%s/%s/contents/.forgejo/workflows", c.owner, c.repo)
209 if err := c.do(http.MethodGet, path, nil, &entries); err != nil {
210 if strings.Contains(err.Error(), "HTTP 404") {
211 return nil, nil
212 }
213 return nil, err
214 }
215 names := make([]string, 0, len(entries))
216 for _, e := range entries {
217 if e.Type == "file" {
218 names = append(names, e.Name)
219 }
220 }
221 return names, nil
222}
223
224// GetFileContent fetches and decodes one file's text content.
225func (c *Client) GetFileContent(path string) (string, error) {
226 var entry contentEntry
227 apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", c.owner, c.repo, path)
228 if err := c.do(http.MethodGet, apiPath, nil, &entry); err != nil {
229 return "", err
230 }
231 if entry.Encoding == "base64" {
232 decoded, err := base64.StdEncoding.DecodeString(entry.Content)
233 if err != nil {
234 return "", fmt.Errorf("decode %s: %w", path, err)
235 }
236 return string(decoded), nil
237 }
238 return entry.Content, nil
239}