package forgejo import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) type Client struct { baseURL string apiToken string httpClient *http.Client BotLogin string } func NewClient(baseURL, apiToken string) *Client { return &Client{ baseURL: baseURL, apiToken: apiToken, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } type CreateIssueRequest struct { Title string `json:"title"` Body string `json:"body"` Labels []int64 `json:"labels,omitempty"` } type EditIssueRequest struct { State string `json:"state,omitempty"` } type Label struct { ID int64 `json:"id"` Name string `json:"name"` } type Issue struct { Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []Label `json:"labels"` CreatedAt time.Time `json:"created_at"` } type CreateCommentRequest struct { Body string `json:"body"` } type Comment struct { ID int64 `json:"id"` Body string `json:"body"` User APIUser `json:"user"` CreatedAt time.Time `json:"created_at"` } type APIUser struct { Login string `json:"login"` FullName string `json:"full_name"` Email string `json:"email"` } // DisplayName returns the best human-readable name for the user. func (u APIUser) DisplayName() string { if u.FullName != "" { return u.FullName } // Ignore Forgejo's privacy placeholder emails if u.Email != "" && !strings.HasSuffix(u.Email, "@noreply.localhost") { return u.Email } return u.Login } type CommentView struct { Body string AuthorName string IsTeam bool CreatedAt time.Time } // DeriveStatus maps Forgejo issue state + labels to app status. func DeriveStatus(issue *Issue) string { if issue.State == "closed" { return "closed" } for _, l := range issue.Labels { if l.Name == "in_progress" { return "in_progress" } } return "open" } // StripCommentFooter removes the "---\n*...*" footer from bot-posted comments // and returns the clean body and the attribution (email). func StripCommentFooter(body string) (string, string) { sep := "\n\n---\n*" idx := strings.LastIndex(body, sep) if idx == -1 { return body, "" } footer := body[idx+len(sep)-1:] // starts at "*..." cleanBody := body[:idx] if len(footer) > 1 && footer[len(footer)-1] == '*' { inner := footer[1 : len(footer)-1] parts := strings.SplitN(inner, ": ", 2) if len(parts) == 2 { return cleanBody, parts[1] } } return cleanBody, "" } // BuildCommentViews transforms Forgejo comments into view models, // identifying customer vs team comments. A comment is considered a // customer comment if it has a recognizable footer (primary check) OR // if its author matches the bot login (fallback). func BuildCommentViews(comments []Comment, botLogin string) []CommentView { var views []CommentView for _, c := range comments { body, email := StripCommentFooter(c.Body) isCustomer := email != "" || (botLogin != "" && c.User.Login == botLogin) if isCustomer { authorName := email if authorName == "" { authorName = "Customer" } views = append(views, CommentView{ Body: body, AuthorName: authorName, IsTeam: false, CreatedAt: c.CreatedAt, }) } else { views = append(views, CommentView{ Body: c.Body, AuthorName: c.User.DisplayName(), IsTeam: true, CreatedAt: c.CreatedAt, }) } } return views } func GenerateWebhookSecret() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // InitBotLogin calls GetAuthenticatedUser and stores the login on the client. func (c *Client) InitBotLogin() error { user, err := c.GetAuthenticatedUser() if err != nil { return err } c.BotLogin = user.Login return nil } func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, error) { // Try to find existing label listURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", c.baseURL, owner, repo) httpReq, err := http.NewRequest("GET", listURL, nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { var labels []Label if err := json.NewDecoder(resp.Body).Decode(&labels); err == nil { for _, l := range labels { if l.Name == labelName { return &l, nil } } } } // Create the label createBody, _ := json.Marshal(map[string]string{ "name": labelName, "color": color, }) httpReq, err = http.NewRequest("POST", listURL, bytes.NewReader(createBody)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "token "+c.apiToken) resp2, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp2.Body.Close() if resp2.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp2.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp2.StatusCode, string(respBody)) } var label Label if err := json.NewDecoder(resp2.Body).Decode(&label); err != nil { return nil, err } return &label, nil } func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) body, err := json.Marshal(req) if err != nil { return nil, err } httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, err } return &issue, nil } func (c *Client) CreateComment(owner, repo string, issueNumber int64, req CreateCommentRequest) (*Comment, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber) body, err := json.Marshal(req) if err != nil { return nil, err } httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var comment Comment if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil { return nil, err } return &comment, nil } func (c *Client) GetIssue(owner, repo string, number int64) (*Issue, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, owner, repo, number) httpReq, err := http.NewRequest("GET", reqURL, nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, err } return &issue, nil } func (c *Client) ListIssueComments(owner, repo string, number int64) ([]Comment, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, number) httpReq, err := http.NewRequest("GET", reqURL, nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var comments []Comment if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { return nil, err } return comments, nil } func (c *Client) ListIssues(owner, repo, state, labels string) ([]Issue, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) params := url.Values{} params.Set("type", "issues") params.Set("limit", "50") if state != "" { params.Set("state", state) } if labels != "" { params.Set("labels", labels) } reqURL += "?" + params.Encode() httpReq, err := http.NewRequest("GET", reqURL, nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var issues []Issue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, err } return issues, nil } func (c *Client) EditIssue(owner, repo string, number int64, req EditIssueRequest) error { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, owner, repo, number) body, err := json.Marshal(req) if err != nil { return err } httpReq, err := http.NewRequest("PATCH", reqURL, bytes.NewReader(body)) if err != nil { return err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } return nil } func (c *Client) AddLabels(owner, repo string, number int64, labelIDs []int64) error { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", c.baseURL, owner, repo, number) body, err := json.Marshal(map[string][]int64{"labels": labelIDs}) if err != nil { return err } httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) if err != nil { return err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } return nil } func (c *Client) RemoveLabel(owner, repo string, number int64, labelID int64) error { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels/%d", c.baseURL, owner, repo, number, labelID) httpReq, err := http.NewRequest("DELETE", reqURL, nil) if err != nil { return err } httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } return nil } func (c *Client) GetAuthenticatedUser() (*APIUser, error) { reqURL := fmt.Sprintf("%s/api/v1/user", c.baseURL) httpReq, err := http.NewRequest("GET", reqURL, nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "token "+c.apiToken) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("forgejo API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var user APIUser if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, err } return &user, nil }