Federated via graft
0

Configure Feed

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

graft-source / internal / forgejo / client.go
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}