package forgejo import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) type Client struct { baseURL string token string httpClient *http.Client } func NewClient(baseURL, token string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), token: token, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } func (c *Client) BaseURL() string { return c.baseURL } // Issue represents a Forgejo issue. type Issue struct { ID int64 `json:"id"` Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` HTMLURL string `json:"html_url"` } // CreateIssueRequest is the body for creating a Forgejo issue. type CreateIssueRequest struct { Title string `json:"title"` Body string `json:"body"` Labels []int64 `json:"labels,omitempty"` } // CommitStatus represents a Forgejo commit status. type CommitStatus struct { State string `json:"state"` TargetURL string `json:"target_url,omitempty"` Description string `json:"description"` Context string `json:"context"` } // CreateIssue creates a new issue on a Forgejo repository. func (c *Client) CreateIssue(ctx context.Context, owner, repo string, req CreateIssueRequest) (*Issue, error) { path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo) var issue Issue if err := c.post(ctx, path, req, &issue); err != nil { return nil, fmt.Errorf("creating issue: %w", err) } return &issue, nil } // UpdateIssueState changes the state of an issue (open/closed). func (c *Client) UpdateIssueState(ctx context.Context, owner, repo string, number int, state string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) body := map[string]string{"state": state} return c.patch(ctx, path, body) } // CreateCommitStatus posts a commit status (success/failure/pending). func (c *Client) CreateCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, sha) return c.post(ctx, path, status, nil) } // CommentOnIssue adds a comment to an issue. func (c *Client) CommentOnIssue(ctx context.Context, owner, repo string, number int, body string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number) return c.post(ctx, path, map[string]string{"body": body}, nil) } // CreateWebhook registers a webhook on a repository. func (c *Client) CreateWebhook(ctx context.Context, owner, repo, targetURL, secret string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/hooks", owner, repo) body := map[string]any{ "type": "forgejo", "active": true, "config": map[string]string{ "url": targetURL, "content_type": "json", "secret": secret, }, "events": []string{"push", "issues", "pull_request"}, } return c.post(ctx, path, body, nil) } func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) { var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() return nil, fmt.Errorf("forgejo API %s %s: %d %s", method, path, resp.StatusCode, string(respBody)) } return resp, nil } func (c *Client) post(ctx context.Context, path string, body any, result any) error { resp, err := c.do(ctx, http.MethodPost, path, body) if err != nil { return err } defer resp.Body.Close() if result != nil { return json.NewDecoder(resp.Body).Decode(result) } return nil } func (c *Client) patch(ctx context.Context, path string, body any) error { resp, err := c.do(ctx, http.MethodPatch, path, body) if err != nil { return err } _ = resp.Body.Close() return nil }