Federated via graft
5.9 kB
203 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// Client talks to one Forgejo instance on behalf of one repository.
18type Client struct {
19 baseURL string
20 owner string
21 repo string
22 token string
23 http *http.Client
24}
25
26func New(baseURL, owner, repo, token string) *Client {
27 return &Client{
28 baseURL: strings.TrimRight(baseURL, "/"),
29 owner: owner,
30 repo: repo,
31 token: token,
32 http: &http.Client{Timeout: 30 * time.Second},
33 }
34}
35
36func (c *Client) do(method, path string, body any, out any) error {
37 var reqBody io.Reader
38 if body != nil {
39 b, err := json.Marshal(body)
40 if err != nil {
41 return fmt.Errorf("encode request: %w", err)
42 }
43 reqBody = bytes.NewReader(b)
44 }
45
46 url := c.baseURL + "/api/v1" + path
47 req, err := http.NewRequest(method, url, reqBody)
48 if err != nil {
49 return fmt.Errorf("build request: %w", err)
50 }
51 req.Header.Set("Authorization", "token "+c.token)
52 req.Header.Set("Content-Type", "application/json")
53
54 resp, err := c.http.Do(req)
55 if err != nil {
56 return fmt.Errorf("%s %s: %w", method, path, err)
57 }
58 defer resp.Body.Close()
59
60 if resp.StatusCode >= 300 {
61 respBody, _ := io.ReadAll(resp.Body)
62 return fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
63 }
64
65 if out != nil {
66 if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
67 return fmt.Errorf("decode response for %s %s: %w", method, path, err)
68 }
69 }
70 return nil
71}
72
73// Repository is the subset of Forgejo's repository object the daemon uses.
74type Repository struct {
75 CloneURL string `json:"clone_url"`
76 DefaultBranch string `json:"default_branch"`
77}
78
79func (c *Client) GetRepository() (*Repository, error) {
80 var repo Repository
81 path := fmt.Sprintf("/repos/%s/%s", c.owner, c.repo)
82 if err := c.do(http.MethodGet, path, nil, &repo); err != nil {
83 return nil, err
84 }
85 return &repo, nil
86}
87
88// Issue is the subset of Forgejo's issue object the daemon reads and writes.
89type Issue struct {
90 Index int64 `json:"number"`
91 Title string `json:"title"`
92 Body string `json:"body"`
93 State string `json:"state"`
94}
95
96func (c *Client) ListIssues() ([]Issue, error) {
97 var issues []Issue
98 path := fmt.Sprintf("/repos/%s/%s/issues?type=issues&state=all&limit=50", c.owner, c.repo)
99 if err := c.do(http.MethodGet, path, nil, &issues); err != nil {
100 return nil, err
101 }
102 return issues, nil
103}
104
105func (c *Client) CreateIssue(title, body string) (*Issue, error) {
106 var issue Issue
107 path := fmt.Sprintf("/repos/%s/%s/issues", c.owner, c.repo)
108 req := map[string]string{"title": title, "body": body}
109 if err := c.do(http.MethodPost, path, req, &issue); err != nil {
110 return nil, err
111 }
112 return &issue, nil
113}
114
115// PullRequest is the subset of Forgejo's pull request object the daemon
116// reads and writes.
117type PullRequest struct {
118 Index int64 `json:"number"`
119 Title string `json:"title"`
120 Body string `json:"body"`
121 State string `json:"state"`
122 Head Branch `json:"head"`
123 Base Branch `json:"base"`
124}
125
126type Branch struct {
127 Ref string `json:"ref"`
128}
129
130func (c *Client) ListPullRequests() ([]PullRequest, error) {
131 var prs []PullRequest
132 path := fmt.Sprintf("/repos/%s/%s/pulls?state=all&limit=50", c.owner, c.repo)
133 if err := c.do(http.MethodGet, path, nil, &prs); err != nil {
134 return nil, err
135 }
136 return prs, nil
137}
138
139func (c *Client) CreatePullRequest(title, body, head, base string) (*PullRequest, error) {
140 var pr PullRequest
141 path := fmt.Sprintf("/repos/%s/%s/pulls", c.owner, c.repo)
142 req := map[string]string{"title": title, "body": body, "head": head, "base": base}
143 if err := c.do(http.MethodPost, path, req, &pr); err != nil {
144 return nil, err
145 }
146 return &pr, nil
147}
148
149// CreateIssueComment adds a comment to an issue or pull request — Forgejo
150// (like Gitea) treats PR comments as issue comments under the same
151// endpoint, verified against a live PR on f1.
152func (c *Client) CreateIssueComment(index int64, body string) error {
153 path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", c.owner, c.repo, index)
154 req := map[string]string{"body": body}
155 return c.do(http.MethodPost, path, req, nil)
156}
157
158// contentEntry is the subset of Forgejo's contents API response this
159// client reads, shared by both directory listings and single-file fetches.
160type contentEntry struct {
161 Name string `json:"name"`
162 Type string `json:"type"` // "file" or "dir"
163 Content string `json:"content,omitempty"`
164 Encoding string `json:"encoding,omitempty"`
165}
166
167// ListWorkflowFiles lists file names directly under .forgejo/workflows/.
168// Returns an empty slice (not an error) if the directory doesn't exist —
169// most repos have no workflows at all, which is a normal, expected state.
170func (c *Client) ListWorkflowFiles() ([]string, error) {
171 var entries []contentEntry
172 path := fmt.Sprintf("/repos/%s/%s/contents/.forgejo/workflows", c.owner, c.repo)
173 if err := c.do(http.MethodGet, path, nil, &entries); err != nil {
174 if strings.Contains(err.Error(), "HTTP 404") {
175 return nil, nil
176 }
177 return nil, err
178 }
179 names := make([]string, 0, len(entries))
180 for _, e := range entries {
181 if e.Type == "file" {
182 names = append(names, e.Name)
183 }
184 }
185 return names, nil
186}
187
188// GetFileContent fetches and decodes one file's text content.
189func (c *Client) GetFileContent(path string) (string, error) {
190 var entry contentEntry
191 apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", c.owner, c.repo, path)
192 if err := c.do(http.MethodGet, apiPath, nil, &entry); err != nil {
193 return "", err
194 }
195 if entry.Encoding == "base64" {
196 decoded, err := base64.StdEncoding.DecodeString(entry.Content)
197 if err != nil {
198 return "", fmt.Errorf("decode %s: %w", path, err)
199 }
200 return string(decoded), nil
201 }
202 return entry.Content, nil
203}