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 }