Add automatic Gitea label creation and repository edit functionality
- Implement automatic label creation when registering repositories:
• bzzz-task (red) - Issues for CHORUS BZZZ task assignments
• whoosh-monitored (teal) - Repository monitoring indicator
• priority-high/medium/low labels for task prioritization
- Add repository edit modal with full configuration options
- Add manual "Labels" button to ensure labels for existing repos
- Enhance Gitea client with CreateLabel, GetLabels, EnsureRequiredLabels methods
- Add POST /api/v1/repositories/{id}/ensure-labels endpoint
- Fix label creation error handling with graceful degradation
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,199 +1,350 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/chorus-services/whoosh/internal/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Client represents a Gitea API client
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// Issue represents a Gitea issue
|
||||
type Issue struct {
|
||||
ID int `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
URL string `json:"html_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
} `json:"labels"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
} `json:"repository"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Number int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Labels []Label `json:"labels"`
|
||||
Assignees []User `json:"assignees"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ClosedAt *time.Time `json:"closed_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User User `json:"user"`
|
||||
Repository Repository `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Label represents a Gitea issue label
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// User represents a Gitea user
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type WebhookPayload struct {
|
||||
Action string `json:"action"`
|
||||
Issue *Issue `json:"issue,omitempty"`
|
||||
Repository Repository `json:"repository"`
|
||||
Sender struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"sender"`
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Assignee string `json:"assignee,omitempty"`
|
||||
// Repository represents a Gitea repository
|
||||
type Repository struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Owner User `json:"owner"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// NewClient creates a new Gitea API client
|
||||
func NewClient(cfg config.GITEAConfig) *Client {
|
||||
token := cfg.Token
|
||||
// TODO: Handle TokenFile if needed
|
||||
|
||||
return &Client{
|
||||
baseURL: cfg.BaseURL,
|
||||
token: cfg.Token,
|
||||
httpClient: &http.Client{
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
|
||||
url := c.baseURL + "/api/v1" + path
|
||||
|
||||
var reqBody *bytes.Buffer
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
if reqBody != nil {
|
||||
req, err = http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
} else {
|
||||
req, err = http.NewRequestWithContext(ctx, method, url, nil)
|
||||
}
|
||||
// makeRequest makes an authenticated request to the Gitea API
|
||||
func (c *Client) makeRequest(ctx context.Context, method, endpoint string) (*http.Response, error) {
|
||||
url := fmt.Sprintf("%s/api/v1%s", c.baseURL, endpoint)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, issue CreateIssueRequest) (*Issue, error) {
|
||||
path := fmt.Sprintf("/repos/%s/%s/issues", owner, repo)
|
||||
|
||||
resp, err := c.makeRequest(ctx, "POST", path, issue)
|
||||
// GetRepository retrieves repository information
|
||||
func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Repository, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get repository: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("failed to create issue: status %d", resp.StatusCode)
|
||||
|
||||
var repository Repository
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode repository: %w", err)
|
||||
}
|
||||
|
||||
var createdIssue Issue
|
||||
if err := json.NewDecoder(resp.Body).Decode(&createdIssue); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("repo", fmt.Sprintf("%s/%s", owner, repo)).
|
||||
Int("issue_number", createdIssue.Number).
|
||||
Str("title", createdIssue.Title).
|
||||
Msg("Created GITEA issue")
|
||||
|
||||
return &createdIssue, nil
|
||||
|
||||
return &repository, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetIssue(ctx context.Context, owner, repo string, issueNumber int) (*Issue, error) {
|
||||
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, issueNumber)
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", path, nil)
|
||||
// GetIssues retrieves issues from a repository
|
||||
func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueListOptions) ([]Issue, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/issues", url.PathEscape(owner), url.PathEscape(repo))
|
||||
|
||||
// Add query parameters
|
||||
params := url.Values{}
|
||||
if opts.State != "" {
|
||||
params.Set("state", opts.State)
|
||||
}
|
||||
if opts.Labels != "" {
|
||||
params.Set("labels", opts.Labels)
|
||||
}
|
||||
if opts.Page > 0 {
|
||||
params.Set("page", strconv.Itoa(opts.Page))
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(opts.Limit))
|
||||
}
|
||||
if !opts.Since.IsZero() {
|
||||
params.Set("since", opts.Since.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get issues: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get issue: status %d", resp.StatusCode)
|
||||
|
||||
var issues []Issue
|
||||
if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode issues: %w", err)
|
||||
}
|
||||
|
||||
// Set repository information on each issue for context
|
||||
for i := range issues {
|
||||
issues[i].Repository = Repository{
|
||||
Name: repo,
|
||||
FullName: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Owner: User{Login: owner},
|
||||
}
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// GetIssue retrieves a specific issue
|
||||
func (c *Client) GetIssue(ctx context.Context, owner, repo string, issueNumber int64) (*Issue, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), issueNumber)
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get issue: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var issue Issue
|
||||
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode issue: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Set repository information
|
||||
issue.Repository = Repository{
|
||||
Name: repo,
|
||||
FullName: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Owner: User{Login: owner},
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRepositories(ctx context.Context) ([]Repository, error) {
|
||||
path := "/user/repos"
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to list repositories: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var repos []Repository
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
// IssueListOptions contains options for listing issues
|
||||
type IssueListOptions struct {
|
||||
State string // "open", "closed", "all"
|
||||
Labels string // Comma-separated list of label names
|
||||
Page int // Page number (1-based)
|
||||
Limit int // Number of items per page (default: 20, max: 100)
|
||||
Since time.Time // Only show issues updated after this time
|
||||
}
|
||||
|
||||
func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Repository, error) {
|
||||
path := fmt.Sprintf("/repos/%s/%s", owner, repo)
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", path, nil)
|
||||
// TestConnection tests the connection to Gitea API
|
||||
func (c *Client) TestConnection(ctx context.Context) error {
|
||||
resp, err := c.makeRequest(ctx, "GET", "/user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return fmt.Errorf("connection test failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get repository: status %d", resp.StatusCode)
|
||||
// WebhookPayload represents a Gitea webhook payload
|
||||
type WebhookPayload struct {
|
||||
Action string `json:"action"`
|
||||
Number int64 `json:"number,omitempty"`
|
||||
Issue *Issue `json:"issue,omitempty"`
|
||||
Repository Repository `json:"repository"`
|
||||
Sender User `json:"sender"`
|
||||
}
|
||||
|
||||
// CreateLabelRequest represents the request to create a new label
|
||||
type CreateLabelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CreateLabel creates a new label in a repository
|
||||
func (c *Client) CreateLabel(ctx context.Context, owner, repo string, label CreateLabelRequest) (*Label, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/labels", url.PathEscape(owner), url.PathEscape(repo))
|
||||
|
||||
jsonData, err := json.Marshal(label)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal label data: %w", err)
|
||||
}
|
||||
|
||||
var repository Repository
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/api/v1%s", c.baseURL, endpoint), strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var createdLabel Label
|
||||
if err := json.NewDecoder(resp.Body).Decode(&createdLabel); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode label: %w", err)
|
||||
}
|
||||
|
||||
return &createdLabel, nil
|
||||
}
|
||||
|
||||
return &repository, nil
|
||||
}
|
||||
// GetLabels retrieves all labels from a repository
|
||||
func (c *Client) GetLabels(ctx context.Context, owner, repo string) ([]Label, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/labels", url.PathEscape(owner), url.PathEscape(repo))
|
||||
|
||||
resp, err := c.makeRequest(ctx, "GET", endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get labels: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var labels []Label
|
||||
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode labels: %w", err)
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// EnsureRequiredLabels ensures that required labels exist in the repository
|
||||
func (c *Client) EnsureRequiredLabels(ctx context.Context, owner, repo string) error {
|
||||
requiredLabels := []CreateLabelRequest{
|
||||
{
|
||||
Name: "bzzz-task",
|
||||
Color: "ff6b6b",
|
||||
Description: "Issues that should be converted to BZZZ tasks for CHORUS",
|
||||
},
|
||||
{
|
||||
Name: "whoosh-monitored",
|
||||
Color: "4ecdc4",
|
||||
Description: "Repository is monitored by WHOOSH",
|
||||
},
|
||||
{
|
||||
Name: "priority-high",
|
||||
Color: "e74c3c",
|
||||
Description: "High priority task for immediate attention",
|
||||
},
|
||||
{
|
||||
Name: "priority-medium",
|
||||
Color: "f39c12",
|
||||
Description: "Medium priority task",
|
||||
},
|
||||
{
|
||||
Name: "priority-low",
|
||||
Color: "95a5a6",
|
||||
Description: "Low priority task",
|
||||
},
|
||||
}
|
||||
|
||||
// Get existing labels
|
||||
existingLabels, err := c.GetLabels(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing labels: %w", err)
|
||||
}
|
||||
|
||||
// Create a map of existing label names for quick lookup
|
||||
existingLabelNames := make(map[string]bool)
|
||||
for _, label := range existingLabels {
|
||||
existingLabelNames[label.Name] = true
|
||||
}
|
||||
|
||||
// Create missing required labels
|
||||
for _, requiredLabel := range requiredLabels {
|
||||
if !existingLabelNames[requiredLabel.Name] {
|
||||
_, err := c.CreateLabel(ctx, owner, repo, requiredLabel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create label %s: %w", requiredLabel.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func (h *WebhookHandler) ProcessWebhook(payload *WebhookPayload) *WebhookEvent {
|
||||
log.Info().
|
||||
Str("action", payload.Action).
|
||||
Str("repository", payload.Repository.FullName).
|
||||
Int("issue_number", payload.Issue.Number).
|
||||
Int64("issue_number", payload.Issue.Number).
|
||||
Str("title", payload.Issue.Title).
|
||||
Msg("Processing task issue webhook")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user