This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const pageSize = 50
// Client calls the Gitea REST API with a fixed host and token.
type Client struct {
Host string
Token string
httpClient *http.Client
}
// New returns a Client with a 30-second timeout.
func New(host, token string) *Client {
return &Client{
Host: normalizeHost(host),
Token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// normalizeHost ensures the host is a scheme-qualified base URL without a trailing slash.
func normalizeHost(host string) string {
host = strings.TrimRight(host, "/")
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
return host
}
// APIError is returned for any non-2xx response so callers can inspect the
// HTTP status (e.g. 401 = token invalid, 404 = not found).
type APIError struct {
Status int
Method string
URL string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("gitea API %s %s: status %d: %s", e.Method, e.URL, e.Status, e.Body)
}
// User is the minimal Gitea user shape used by this client.
type User struct {
Login string `json:"login"`
ID int `json:"id"`
}
// Issue mirrors the Gitea issue API response fields used by this application.
type Issue struct {
ID int `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
Comments int `json:"comments"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Body string `json:"body"`
HtmlURL string `json:"html_url"`
User *struct {
Login string `json:"login"`
} `json:"user"`
Assignees []struct {
Login string `json:"login"`
} `json:"assignees"`
Labels []struct {
Name string `json:"name"`
Color string `json:"color"`
} `json:"labels"`
PullRequest *struct{} `json:"pull_request"` // non-nil when issue is a PR
}
// Comment mirrors the Gitea issue comment API response.
type Comment struct {
ID int `json:"id"`
Body string `json:"body"`
IssueURL string `json:"issue_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// PullRequest mirrors the Gitea pull request API response fields used here.
type PullRequest struct {
Number int `json:"number"`
Merged bool `json:"merged"`
State string `json:"state"`
MergedAt string `json:"merged_at"`
HtmlURL string `json:"html_url"`
Body string `json:"body"`
Base struct {
Ref string `json:"ref"`
} `json:"base"`
}
// PullRequestFile mirrors one file entry from the PR files endpoint.
type PullRequestFile struct {
Filename string `json:"filename"`
Status string `json:"status"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
// PullRequestCommit mirrors one commit entry from the PR commits endpoint.
type PullRequestCommit struct {
SHA string `json:"sha"`
Commit struct {
Message string `json:"message"`
Author struct {
Name string `json:"name"`
} `json:"author"`
} `json:"commit"`
}
// base returns the API base URL.
func (c *Client) base() string {
return c.Host + "/api/v1"
}
// do executes an HTTP request, attaches auth headers, and returns the response.
// The caller is responsible for closing resp.Body.
func (c *Client) do(ctx context.Context, method, rawURL string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, fmt.Errorf("build request %s %s: %w", method, rawURL, err)
}
req.Header.Set("Authorization", "token "+c.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http %s %s: %w", method, rawURL, err)
}
return resp, nil
}
// checkStatus reads the response body and returns an *APIError for non-2xx.
// It always closes resp.Body.
// decode reads JSON from resp.Body into dst, closes the body, and handles
// non-2xx as an *APIError. On success resp.Body is consumed and closed.
func decode(resp *http.Response, dst any) error {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{
Status: resp.StatusCode,
Method: resp.Request.Method,
URL: resp.Request.URL.String(),
Body: string(b),
}
}
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
return fmt.Errorf("decode response from %s: %w", resp.Request.URL, err)
}
return nil
}
// paginated fetches pages until a page shorter than pageSize is returned,
// calling collect with each batch.
func (c *Client) paginated(ctx context.Context, base *url.URL, collect func([]json.RawMessage) error) error {
for page := 1; ; page++ {
q := base.Query()
q.Set("limit", strconv.Itoa(pageSize))
q.Set("page", strconv.Itoa(page))
base.RawQuery = q.Encode()
resp, err := c.do(ctx, http.MethodGet, base.String(), nil)
if err != nil {
return err
}
var batch []json.RawMessage
if err := decode(resp, &batch); err != nil {
return err
}
if len(batch) == 0 {
break
}
if err := collect(batch); err != nil {
return err
}
if len(batch) < pageSize {
break
}
}
return nil
}
// GetCurrentUser fetches the authenticated user's profile.
func (c *Client) GetCurrentUser(ctx context.Context) (*User, error) {
resp, err := c.do(ctx, http.MethodGet, c.base()+"/user", nil)
if err != nil {
return nil, err
}
var u User
if err := decode(resp, &u); err != nil {
return nil, err
}
return &u, nil
}
// ListIssuesByFilter returns all issues in owner/repo matching the given filter
// query parameter (e.g. "assigned_by" or "created_by") and its value.
func (c *Client) ListIssuesByFilter(ctx context.Context, owner, repo, filterKey, filterVal string) ([]Issue, error) {
u, err := url.Parse(fmt.Sprintf("%s/repos/%s/%s/issues", c.base(), owner, repo))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
q := u.Query()
q.Set("type", "issues")
q.Set("state", "all")
q.Set(filterKey, filterVal)
u.RawQuery = q.Encode()
var out []Issue
err = c.paginated(ctx, u, func(batch []json.RawMessage) error {
for _, raw := range batch {
var iss Issue
if err := json.Unmarshal(raw, &iss); err != nil {
return fmt.Errorf("unmarshal issue: %w", err)
}
out = append(out, iss)
}
return nil
})
return out, err
}
// ListAllRepoComments returns every comment across all issues in owner/repo.
func (c *Client) ListAllRepoComments(ctx context.Context, owner, repo string) ([]Comment, error) {
u, err := url.Parse(fmt.Sprintf("%s/repos/%s/%s/issues/comments", c.base(), owner, repo))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
var out []Comment
err = c.paginated(ctx, u, func(batch []json.RawMessage) error {
for _, raw := range batch {
var cm Comment
if err := json.Unmarshal(raw, &cm); err != nil {
return fmt.Errorf("unmarshal comment: %w", err)
}
out = append(out, cm)
}
return nil
})
return out, err
}
// GetIssue returns a single issue by number. Returns (nil, nil) on 404.
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d", c.base(), owner, repo, number)
resp, err := c.do(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
resp.Body.Close()
return nil, nil
}
var iss Issue
if err := decode(resp, &iss); err != nil {
return nil, err
}
return &iss, nil
}
// GetPullRequest returns a pull request by number.
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
rawURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.base(), owner, repo, number)
resp, err := c.do(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
var pr PullRequest
if err := decode(resp, &pr); err != nil {
return nil, err
}
return &pr, nil
}
// ListIssueComments returns all comments on a specific issue.
func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]Comment, error) {
u, err := url.Parse(fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", c.base(), owner, repo, number))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
var out []Comment
err = c.paginated(ctx, u, func(batch []json.RawMessage) error {
for _, raw := range batch {
var cm Comment
if err := json.Unmarshal(raw, &cm); err != nil {
return fmt.Errorf("unmarshal comment: %w", err)
}
out = append(out, cm)
}
return nil
})
return out, err
}
// PostIssueComment posts a new comment on the given issue and returns the created comment.
func (c *Client) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", c.base(), owner, repo, number)
payload, err := json.Marshal(map[string]string{"body": body})
if err != nil {
return nil, fmt.Errorf("marshal comment body: %w", err)
}
resp, err := c.do(ctx, http.MethodPost, rawURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
var cm Comment
if err := decode(resp, &cm); err != nil {
return nil, err
}
return &cm, nil
}
// GetDependencies returns the issues that the given issue depends on,
// preserving the order returned by the API (first element is the front-loaded prerequisite).
func (c *Client) GetDependencies(ctx context.Context, owner, repo string, number int) ([]Issue, error) {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/dependencies", c.base(), owner, repo, number)
resp, err := c.do(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
var issues []Issue
if err := decode(resp, &issues); err != nil {
return nil, err
}
return issues, nil
}
// CloseIssue sets the issue state to closed via PATCH.
func (c *Client) CloseIssue(ctx context.Context, owner, repo string, number int) error {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d", c.base(), owner, repo, number)
payload, err := json.Marshal(map[string]string{"state": "closed"})
if err != nil {
return fmt.Errorf("marshal close body: %w", err)
}
resp, err := c.do(ctx, http.MethodPatch, rawURL, bytes.NewReader(payload))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{Status: resp.StatusCode, Method: http.MethodPatch, URL: rawURL, Body: string(b)}
}
return nil
}
// DeleteIssue hard-deletes the issue from Gitea.
func (c *Client) DeleteIssue(ctx context.Context, owner, repo string, number int) error {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d", c.base(), owner, repo, number)
resp, err := c.do(ctx, http.MethodDelete, rawURL, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{Status: resp.StatusCode, Method: http.MethodDelete, URL: rawURL, Body: string(b)}
}
return nil
}
// ClosePullRequest closes a pull request by patching its state to "closed".
// Gitea treats PRs as issues for state changes: PATCH /repos/{owner}/{repo}/issues/{prNum}
func (c *Client) ClosePullRequest(ctx context.Context, owner, repo string, prNumber int) error {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d", c.base(), owner, repo, prNumber)
payload, _ := json.Marshal(map[string]string{"state": "closed"})
resp, err := c.do(ctx, http.MethodPatch, rawURL, bytes.NewReader(payload))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{Status: resp.StatusCode, Method: http.MethodPatch, URL: rawURL, Body: string(b)}
}
return nil
}
// DeleteBranch deletes a remote branch.
// DELETE /repos/{owner}/{repo}/branches/{branch}
func (c *Client) DeleteBranch(ctx context.Context, owner, repo, branch string) error {
rawURL := fmt.Sprintf("%s/repos/%s/%s/branches/%s", c.base(), owner, repo, url.PathEscape(branch))
resp, err := c.do(ctx, http.MethodDelete, rawURL, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{Status: resp.StatusCode, Method: http.MethodDelete, URL: rawURL, Body: string(b)}
}
return nil
}
// AddDependency makes issue number depend on dependsOn.
// POST /api/v1/repos/{owner}/{repo}/issues/{number}/dependencies body: {"index":dependsOn}
func (c *Client) AddDependency(ctx context.Context, owner, repo string, number, dependsOn int) error {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/dependencies", c.base(), owner, repo, number)
payload, err := json.Marshal(map[string]int{"index": dependsOn})
if err != nil {
return fmt.Errorf("marshal add dependency: %w", err)
}
resp, err := c.do(ctx, http.MethodPost, rawURL, bytes.NewReader(payload))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{Status: resp.StatusCode, Method: http.MethodPost, URL: rawURL, Body: string(b)}
}
return nil
}
// RemoveDependency removes the dependency of issue number on dependsOn.
// DELETE /api/v1/repos/{owner}/{repo}/issues/{number}/dependencies body: {"index":dependsOn}
func (c *Client) RemoveDependency(ctx context.Context, owner, repo string, number, dependsOn int) error {
rawURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/dependencies", c.base(), owner, repo, number)
payload, err := json.Marshal(map[string]int{"index": dependsOn})
if err != nil {
return fmt.Errorf("marshal remove dependency: %w", err)
}
resp, err := c.do(ctx, http.MethodDelete, rawURL, bytes.NewReader(payload))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &APIError{Status: resp.StatusCode, Method: http.MethodDelete, URL: rawURL, Body: string(b)}
}
return nil
}
// ListPullRequestFiles returns all files changed in a pull request.
func (c *Client) ListPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]PullRequestFile, error) {
u, err := url.Parse(fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files", c.base(), owner, repo, number))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
var out []PullRequestFile
err = c.paginated(ctx, u, func(batch []json.RawMessage) error {
for _, raw := range batch {
var f PullRequestFile
if err := json.Unmarshal(raw, &f); err != nil {
return fmt.Errorf("unmarshal pr file: %w", err)
}
out = append(out, f)
}
return nil
})
return out, err
}
// ListPullRequestCommits returns all commits of a pull request, following pagination.
func (c *Client) ListPullRequestCommits(ctx context.Context, owner, repo string, number int) ([]PullRequestCommit, error) {
u, err := url.Parse(fmt.Sprintf("%s/repos/%s/%s/pulls/%d/commits", c.base(), owner, repo, number))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
var out []PullRequestCommit
err = c.paginated(ctx, u, func(batch []json.RawMessage) error {
for _, raw := range batch {
var commit PullRequestCommit
if err := json.Unmarshal(raw, &commit); err != nil {
return fmt.Errorf("unmarshal pr commit: %w", err)
}
out = append(out, commit)
}
return nil
})
return out, err
}