Federated via graft
6.6 kB
220 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// contentEntry is the subset of Forgejo's contents API response this
176// client reads, shared by both directory listings and single-file fetches.
177type contentEntry struct {
178 Name string `json:"name"`
179 Type string `json:"type"` // "file" or "dir"
180 Content string `json:"content,omitempty"`
181 Encoding string `json:"encoding,omitempty"`
182}
183
184// ListWorkflowFiles lists file names directly under .forgejo/workflows/.
185// Returns an empty slice (not an error) if the directory doesn't exist —
186// most repos have no workflows at all, which is a normal, expected state.
187func (c *Client) ListWorkflowFiles() ([]string, error) {
188 var entries []contentEntry
189 path := fmt.Sprintf("/repos/%s/%s/contents/.forgejo/workflows", c.owner, c.repo)
190 if err := c.do(http.MethodGet, path, nil, &entries); err != nil {
191 if strings.Contains(err.Error(), "HTTP 404") {
192 return nil, nil
193 }
194 return nil, err
195 }
196 names := make([]string, 0, len(entries))
197 for _, e := range entries {
198 if e.Type == "file" {
199 names = append(names, e.Name)
200 }
201 }
202 return names, nil
203}
204
205// GetFileContent fetches and decodes one file's text content.
206func (c *Client) GetFileContent(path string) (string, error) {
207 var entry contentEntry
208 apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", c.owner, c.repo, path)
209 if err := c.do(http.MethodGet, apiPath, nil, &entry); err != nil {
210 return "", err
211 }
212 if entry.Encoding == "base64" {
213 decoded, err := base64.StdEncoding.DecodeString(entry.Content)
214 if err != nil {
215 return "", fmt.Errorf("decode %s: %w", path, err)
216 }
217 return string(decoded), nil
218 }
219 return entry.Content, nil
220}