485 lines
12 KiB
Go
485 lines
12 KiB
Go
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"`
|
|
}
|
|
|
|
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 based on the bot login.
|
|
func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
|
|
var views []CommentView
|
|
for _, c := range comments {
|
|
if c.User.Login == botLogin {
|
|
body, email := StripCommentFooter(c.Body)
|
|
authorName := email
|
|
if authorName == "" {
|
|
authorName = "Customer"
|
|
}
|
|
views = append(views, CommentView{
|
|
Body: body,
|
|
AuthorName: authorName,
|
|
IsTeam: false,
|
|
CreatedAt: c.CreatedAt,
|
|
})
|
|
} else {
|
|
name := c.User.FullName
|
|
if name == "" {
|
|
name = c.User.Login
|
|
}
|
|
views = append(views, CommentView{
|
|
Body: c.Body,
|
|
AuthorName: name,
|
|
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
|
|
}
|