831 lines
23 KiB
Go
831 lines
23 KiB
Go
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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, ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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.
|
|
// The URL host must match the configured Forgejo base URL to prevent SSRF.
|
|
func (c *Client) ProxyDownload(downloadURL string) (*http.Response, error) {
|
|
parsed, err := url.Parse(downloadURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid download URL: %w", err)
|
|
}
|
|
base, err := url.Parse(c.baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid base URL: %w", err)
|
|
}
|
|
if parsed.Host != base.Host {
|
|
return nil, fmt.Errorf("download URL host %q does not match Forgejo host %q", parsed.Host, base.Host)
|
|
}
|
|
|
|
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)
|
|
}
|