package forgejo import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/url" "regexp" "sort" "strconv" "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, }, } } // BaseURL returns the Forgejo base URL for proxy download purposes. func (c *Client) BaseURL() string { return c.baseURL } // APIToken returns the API token for authenticated proxy requests. func (c *Client) APIToken() string { return c.apiToken } 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"` Color string `json:"color"` } type Attachment struct { ID int64 `json:"id"` Name string `json:"name"` Size int64 `json:"size"` DownloadURL string `json:"browser_download_url"` Created time.Time `json:"created_at"` } type Issue struct { Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []Label `json:"labels"` Assignees []APIUser `json:"assignees"` DueDate *time.Time `json:"due_date"` PinOrder int `json:"pin_order"` Assets []Attachment `json:"assets"` 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"` Assets []Attachment `json:"assets"` 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 Attachments []Attachment } // TimelineEvent represents a single event in the issue timeline from Forgejo. type TimelineEvent struct { ID int64 `json:"id"` Type string `json:"type"` CreatedAt time.Time `json:"created_at"` User APIUser `json:"user"` Body string `json:"body"` Label *Label `json:"label"` Assignee *APIUser `json:"assignee"` OldRef string `json:"old_ref"` Assets []Attachment `json:"assets"` } // TimelineView is the template-friendly representation of a timeline event. type TimelineView struct { Type string // "comment", "status_change", "assignment", "label" Body string AuthorName string IsTeam bool EventText string CreatedAt time.Time Attachments []Attachment CommentID int64 // needed so templates can generate comment-asset download URLs } // RelatedIssue represents a cross-referenced issue with visibility info. type RelatedIssue struct { Number int64 Title string IsVisible bool DisplayText string TicketID string // customer-facing UUID if visible } // 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" } // DerivePriority returns "high", "medium", "low", or "" from issue labels. func DerivePriority(issue *Issue) string { for _, l := range issue.Labels { switch l.Name { case "priority/high": return "high" case "priority/medium": return "medium" case "priority/low": return "low" } } return "" } // 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. 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, Attachments: c.Assets, }) } else { views = append(views, CommentView{ Body: c.Body, AuthorName: c.User.DisplayName(), IsTeam: true, CreatedAt: c.CreatedAt, Attachments: c.Assets, }) } } return views } // BuildTimelineViews converts raw timeline events into template-friendly views. func BuildTimelineViews(events []TimelineEvent, botLogin string, isAdmin bool) []TimelineView { var views []TimelineView for _, e := range events { switch e.Type { case "comment": body, email := StripCommentFooter(e.Body) isCustomer := email != "" || (botLogin != "" && e.User.Login == botLogin) authorName := e.User.DisplayName() if isCustomer { if email != "" { authorName = email } else { authorName = "Customer" } } views = append(views, TimelineView{ Type: "comment", Body: body, AuthorName: authorName, IsTeam: !isCustomer, CreatedAt: e.CreatedAt, Attachments: e.Assets, CommentID: e.ID, }) case "close": views = append(views, TimelineView{ Type: "status_change", AuthorName: e.User.DisplayName(), IsTeam: true, EventText: "closed this ticket", CreatedAt: e.CreatedAt, }) case "reopen": views = append(views, TimelineView{ Type: "status_change", AuthorName: e.User.DisplayName(), IsTeam: true, EventText: "reopened this ticket", CreatedAt: e.CreatedAt, }) case "label": if !isAdmin { continue } action := "added label" labelName := "" if e.Label != nil { labelName = e.Label.Name } views = append(views, TimelineView{ Type: "label", AuthorName: e.User.DisplayName(), IsTeam: true, EventText: action + " " + labelName, CreatedAt: e.CreatedAt, }) case "assignees": if !isAdmin { continue } assigneeName := "" if e.Assignee != nil { assigneeName = e.Assignee.DisplayName() } views = append(views, TimelineView{ Type: "assignment", AuthorName: e.User.DisplayName(), IsTeam: true, EventText: "assigned " + assigneeName, CreatedAt: e.CreatedAt, }) } } return views } var issueRefRegex = regexp.MustCompile(`(?:^|[^\w])#(\d+)\b`) // ExtractIssueReferences finds all #N references in text. func ExtractIssueReferences(text string) []int64 { matches := issueRefRegex.FindAllStringSubmatch(text, -1) seen := map[int64]bool{} var refs []int64 for _, m := range matches { n, err := strconv.ParseInt(m[1], 10, 64) if err != nil || n == 0 { continue } if !seen[n] { seen[n] = true refs = append(refs, n) } } sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] }) return refs } // SortIssuesPinnedFirst sorts issues with pinned (PinOrder > 0) first, then by CreatedAt desc. func SortIssuesPinnedFirst(issues []Issue) { sort.SliceStable(issues, func(i, j int) bool { iPinned := issues[i].PinOrder > 0 jPinned := issues[j].PinOrder > 0 if iPinned != jPinned { return iPinned } if iPinned && jPinned { return issues[i].PinOrder < issues[j].PinOrder } return issues[i].CreatedAt.After(issues[j].CreatedAt) }) } 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) GetLabel(owner, repo, labelName string) (*Label, error) { listURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", 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 { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var labels []Label if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil { return nil, err } for _, l := range labels { if l.Name == labelName { return &l, nil } } return nil, fmt.Errorf("label %q not found in %s/%s", labelName, owner, repo) } func (c *Client) CreateLabel(owner, repo, labelName, color string) (*Label, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", c.baseURL, owner, repo) body, err := json.Marshal(map[string]string{"name": labelName, "color": color}) 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 label Label if err := json.NewDecoder(resp.Body).Decode(&label); err != nil { return nil, err } return &label, nil } // CheckRepoPermission verifies the bot user has write (push) access to the repo. func (c *Client) CheckRepoPermission(owner, repo string) error { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", c.baseURL, owner, repo) httpReq, err := http.NewRequest("GET", 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.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) } var result struct { Permissions struct { Admin bool `json:"admin"` Push bool `json:"push"` Pull bool `json:"pull"` } `json:"permissions"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return err } if !result.Permissions.Push { return fmt.Errorf("bot user does not have write access to %s/%s — add it as a collaborator with Write permission", owner, repo) } return nil } // GetOrCreateLabel looks up a label by name, creating it if it doesn't exist. func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, error) { label, err := c.GetLabel(owner, repo, labelName) if err == nil { return label, nil } return c.CreateLabel(owner, repo, labelName, color) } 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 } // GetUser looks up a Forgejo user by username. func (c *Client) GetUser(username string) (*APIUser, error) { reqURL := fmt.Sprintf("%s/api/v1/users/%s", c.baseURL, username) 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 } // ListIssueTimeline fetches the timeline events for an issue. func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", 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 events []TimelineEvent if err := json.NewDecoder(resp.Body).Decode(&events); err != nil { return nil, err } return events, nil } // CreateIssueAttachment uploads a file to a Forgejo issue. func (c *Client) CreateIssueAttachment(owner, repo string, number int64, filename string, reader io.Reader) (*Attachment, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/assets", c.baseURL, owner, repo, number) return c.uploadAttachment(reqURL, filename, reader) } // CreateCommentAttachment uploads a file to a Forgejo comment. func (c *Client) CreateCommentAttachment(owner, repo string, commentID int64, filename string, reader io.Reader) (*Attachment, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d/assets", c.baseURL, owner, repo, commentID) return c.uploadAttachment(reqURL, filename, reader) } func (c *Client) uploadAttachment(reqURL, filename string, reader io.Reader) (*Attachment, error) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) part, err := writer.CreateFormFile("attachment", filename) if err != nil { return nil, fmt.Errorf("create form file: %w", err) } if _, err := io.Copy(part, reader); err != nil { return nil, fmt.Errorf("copy file data: %w", err) } writer.Close() httpReq, err := http.NewRequest("POST", reqURL, &buf) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", writer.FormDataContentType()) 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 attachment Attachment if err := json.NewDecoder(resp.Body).Decode(&attachment); err != nil { return nil, err } return &attachment, nil } // GetAttachmentURL fetches attachment metadata from the API and returns the browser_download_url. func (c *Client) GetAttachmentURL(apiURL string) (string, error) { resp, err := c.ProxyDownload(apiURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("forgejo API returned %d fetching attachment metadata", resp.StatusCode) } var attachment Attachment if err := json.NewDecoder(resp.Body).Decode(&attachment); err != nil { return "", fmt.Errorf("failed to decode attachment metadata: %w", err) } if attachment.DownloadURL == "" { return "", fmt.Errorf("attachment metadata has no browser_download_url") } return attachment.DownloadURL, nil } // ProxyDownload fetches a file from the given Forgejo URL with authentication and streams it back. func (c *Client) ProxyDownload(downloadURL string) (*http.Response, error) { httpReq, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "token "+c.apiToken) return c.httpClient.Do(httpReq) }