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:
Claude Code
2025-09-09 22:00:29 +10:00
parent 982b63306a
commit 4173c0c8c8
4 changed files with 1155 additions and 141 deletions

View File

@@ -1,199 +1,350 @@
package gitea package gitea
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strconv"
"strings"
"time" "time"
"github.com/chorus-services/whoosh/internal/config" "github.com/chorus-services/whoosh/internal/config"
"github.com/rs/zerolog/log"
) )
// Client represents a Gitea API client
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
httpClient *http.Client client *http.Client
} }
// Issue represents a Gitea issue
type Issue struct { type Issue struct {
ID int `json:"id"` ID int64 `json:"id"`
Number int `json:"number"` Number int64 `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
State string `json:"state"` State string `json:"state"`
URL string `json:"html_url"` Labels []Label `json:"labels"`
HTMLURL string `json:"html_url"` Assignees []User `json:"assignees"`
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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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"`
} }
// 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"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
// Repository represents a Gitea repository
type Repository struct { type Repository struct {
ID int `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
FullName string `json:"full_name"` FullName string `json:"full_name"`
Owner User `json:"owner"`
Description string `json:"description"`
Private bool `json:"private"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"` SSHURL string `json:"ssh_url"`
Language string `json:"language"`
} }
type WebhookPayload struct { // NewClient creates a new Gitea API client
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"`
}
func NewClient(cfg config.GITEAConfig) *Client { func NewClient(cfg config.GITEAConfig) *Client {
token := cfg.Token
// TODO: Handle TokenFile if needed
return &Client{ return &Client{
baseURL: cfg.BaseURL, baseURL: cfg.BaseURL,
token: cfg.Token, token: token,
httpClient: &http.Client{ client: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
} }
} }
func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { // makeRequest makes an authenticated request to the Gitea API
url := c.baseURL + "/api/v1" + path func (c *Client) makeRequest(ctx context.Context, method, endpoint string) (*http.Response, error) {
url := fmt.Sprintf("%s/api/v1%s", c.baseURL, endpoint)
var reqBody *bytes.Buffer req, err := http.NewRequestWithContext(ctx, method, url, nil)
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)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Authorization", "token "+c.token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req) resp, err := c.client.Do(req)
if err != nil { 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 return resp, nil
} }
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, issue CreateIssueRequest) (*Issue, error) { // GetRepository retrieves repository information
path := fmt.Sprintf("/repos/%s/%s/issues", owner, repo) 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, "POST", path, issue) resp, err := c.makeRequest(ctx, "GET", endpoint)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get repository: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated { var repository Repository
return nil, fmt.Errorf("failed to create issue: status %d", resp.StatusCode) if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil {
return nil, fmt.Errorf("failed to decode repository: %w", err)
} }
var createdIssue Issue return &repository, nil
if err := json.NewDecoder(resp.Body).Decode(&createdIssue); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
} }
log.Info(). // GetIssues retrieves issues from a repository
Str("repo", fmt.Sprintf("%s/%s", owner, repo)). func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueListOptions) ([]Issue, error) {
Int("issue_number", createdIssue.Number). endpoint := fmt.Sprintf("/repos/%s/%s/issues", url.PathEscape(owner), url.PathEscape(repo))
Str("title", createdIssue.Title).
Msg("Created GITEA issue")
return &createdIssue, nil // 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))
} }
func (c *Client) GetIssue(ctx context.Context, owner, repo string, issueNumber int) (*Issue, error) { if len(params) > 0 {
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, issueNumber) endpoint += "?" + params.Encode()
}
resp, err := c.makeRequest(ctx, "GET", path, nil) resp, err := c.makeRequest(ctx, "GET", endpoint)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get issues: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { var issues []Issue
return nil, fmt.Errorf("failed to get issue: status %d", resp.StatusCode) 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 var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { 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 return &issue, nil
} }
func (c *Client) ListRepositories(ctx context.Context) ([]Repository, error) { // IssueListOptions contains options for listing issues
path := "/user/repos" 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
}
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 { if err != nil {
return nil, err return fmt.Errorf("connection test failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil
return nil, fmt.Errorf("failed to list repositories: status %d", resp.StatusCode)
} }
var repos []Repository // WebhookPayload represents a Gitea webhook payload
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { type WebhookPayload struct {
return nil, fmt.Errorf("failed to decode response: %w", err) Action string `json:"action"`
Number int64 `json:"number,omitempty"`
Issue *Issue `json:"issue,omitempty"`
Repository Repository `json:"repository"`
Sender User `json:"sender"`
} }
return repos, nil // CreateLabelRequest represents the request to create a new label
type CreateLabelRequest struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
} }
func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Repository, error) { // CreateLabel creates a new label in a repository
path := fmt.Sprintf("/repos/%s/%s", owner, repo) 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))
resp, err := c.makeRequest(ctx, "GET", path, nil) jsonData, err := json.Marshal(label)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to marshal label data: %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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode >= 400 {
return nil, fmt.Errorf("failed to get repository: status %d", resp.StatusCode) return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
} }
var repository Repository var createdLabel Label
if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil { if err := json.NewDecoder(resp.Body).Decode(&createdLabel); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err) return nil, fmt.Errorf("failed to decode label: %w", err)
} }
return &repository, nil return &createdLabel, 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
} }

View File

@@ -178,7 +178,7 @@ func (h *WebhookHandler) ProcessWebhook(payload *WebhookPayload) *WebhookEvent {
log.Info(). log.Info().
Str("action", payload.Action). Str("action", payload.Action).
Str("repository", payload.Repository.FullName). Str("repository", payload.Repository.FullName).
Int("issue_number", payload.Issue.Number). Int64("issue_number", payload.Issue.Number).
Str("title", payload.Issue.Title). Str("title", payload.Issue.Title).
Msg("Processing task issue webhook") Msg("Processing task issue webhook")
} }

571
internal/monitor/monitor.go Normal file
View File

@@ -0,0 +1,571 @@
package monitor
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/chorus-services/whoosh/internal/config"
"github.com/chorus-services/whoosh/internal/gitea"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
// Monitor manages repository monitoring and task creation
type Monitor struct {
db *pgxpool.Pool
gitea *gitea.Client
stopCh chan struct{}
syncInterval time.Duration
}
// NewMonitor creates a new repository monitor
func NewMonitor(db *pgxpool.Pool, giteaCfg config.GITEAConfig) *Monitor {
return &Monitor{
db: db,
gitea: gitea.NewClient(giteaCfg),
stopCh: make(chan struct{}),
syncInterval: 5 * time.Minute, // Default sync every 5 minutes
}
}
// GetGiteaClient returns the Gitea client for external use
func (m *Monitor) GetGiteaClient() *gitea.Client {
return m.gitea
}
// Start begins the monitoring process
func (m *Monitor) Start(ctx context.Context) error {
log.Info().Msg("🔍 Starting repository monitoring service")
// Test Gitea connection
if err := m.gitea.TestConnection(ctx); err != nil {
log.Error().Err(err).Msg("Failed to connect to Gitea")
return fmt.Errorf("gitea connection failed: %w", err)
}
log.Info().Msg("✅ Gitea connection established")
// Start monitoring loop
ticker := time.NewTicker(m.syncInterval)
defer ticker.Stop()
// Initial sync
m.syncAllRepositories(ctx)
for {
select {
case <-ctx.Done():
log.Info().Msg("🛑 Repository monitoring service stopping")
return ctx.Err()
case <-m.stopCh:
log.Info().Msg("🛑 Repository monitoring service stopped")
return nil
case <-ticker.C:
m.syncAllRepositories(ctx)
}
}
}
// Stop stops the monitoring service
func (m *Monitor) Stop() {
close(m.stopCh)
}
// syncAllRepositories syncs all monitored repositories
func (m *Monitor) syncAllRepositories(ctx context.Context) {
log.Info().Msg("🔄 Starting repository sync cycle")
repos, err := m.getMonitoredRepositories(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to get monitored repositories")
return
}
if len(repos) == 0 {
log.Info().Msg("No repositories to monitor")
return
}
log.Info().Int("count", len(repos)).Msg("Syncing repositories")
for _, repo := range repos {
select {
case <-ctx.Done():
return
default:
m.syncRepository(ctx, repo)
}
}
log.Info().Msg("✅ Repository sync cycle completed")
}
// syncRepository syncs a single repository
func (m *Monitor) syncRepository(ctx context.Context, repo RepositoryConfig) {
log.Info().
Str("repository", repo.FullName).
Msg("Syncing repository")
startTime := time.Now()
// Update repository status to active
if err := m.updateRepositoryStatus(ctx, repo.ID, "active", nil); err != nil {
log.Error().Err(err).
Str("repository", repo.FullName).
Msg("Failed to update repository status to active")
}
// Get issues since last sync
opts := gitea.IssueListOptions{
State: "open",
Limit: 100,
}
if repo.LastIssueSync != nil {
opts.Since = *repo.LastIssueSync
}
// Filter by CHORUS task labels if enabled
if repo.EnableChorusIntegration && len(repo.ChorusTaskLabels) > 0 {
opts.Labels = strings.Join(repo.ChorusTaskLabels, ",")
}
issues, err := m.gitea.GetIssues(ctx, repo.Owner, repo.Name, opts)
if err != nil {
m.logSyncError(ctx, repo.ID, "fetch_issues", fmt.Sprintf("Failed to fetch issues: %v", err))
log.Error().Err(err).
Str("repository", repo.FullName).
Msg("Failed to fetch issues")
if err := m.updateRepositoryStatus(ctx, repo.ID, "error", err); err != nil {
log.Error().Err(err).Msg("Failed to update repository status to error")
}
return
}
created := 0
updated := 0
for _, issue := range issues {
// Skip issues without CHORUS labels if CHORUS integration is enabled
if repo.EnableChorusIntegration && !m.hasChorusLabels(issue, repo.ChorusTaskLabels) {
continue
}
taskID, isNew, err := m.createOrUpdateTask(ctx, repo, issue)
if err != nil {
log.Error().Err(err).
Str("repository", repo.FullName).
Int64("issue", issue.Number).
Msg("Failed to create/update task")
continue
}
if isNew {
created++
log.Info().
Str("repository", repo.FullName).
Int64("issue", issue.Number).
Str("task_id", taskID).
Msg("Created task from issue")
} else {
updated++
}
}
duration := time.Since(startTime)
// Update repository sync timestamps and statistics
if err := m.updateRepositorySyncInfo(ctx, repo.ID, time.Now(), created, updated); err != nil {
log.Error().Err(err).
Str("repository", repo.FullName).
Msg("Failed to update repository sync info")
}
// Log successful sync
m.logSyncSuccess(ctx, repo.ID, "full_sync", duration, len(issues), created, updated)
log.Info().
Str("repository", repo.FullName).
Int("issues_processed", len(issues)).
Int("tasks_created", created).
Int("tasks_updated", updated).
Dur("duration", duration).
Msg("Repository sync completed")
}
// createOrUpdateTask creates a new task or updates an existing one from a Gitea issue
func (m *Monitor) createOrUpdateTask(ctx context.Context, repo RepositoryConfig, issue gitea.Issue) (string, bool, error) {
// Check if task already exists
var existingTaskID sql.NullString
query := `SELECT id FROM tasks WHERE external_id = $1 AND source_type = $2`
err := m.db.QueryRow(ctx, query, strconv.FormatInt(issue.Number, 10), repo.SourceType).Scan(&existingTaskID)
if err != nil && err != sql.ErrNoRows {
return "", false, fmt.Errorf("failed to check existing task: %w", err)
}
// Prepare labels
labels := make([]string, len(issue.Labels))
for i, label := range issue.Labels {
labels[i] = label.Name
}
labelsJSON, _ := json.Marshal(labels)
// Determine task status
status := "open"
if issue.State == "closed" {
status = "completed"
}
// Determine priority from labels
priority := m.extractPriorityFromLabels(issue.Labels)
// Extract tech stack from labels and description
techStack := m.extractTechStackFromIssue(issue)
techStackJSON, _ := json.Marshal(techStack)
if existingTaskID.Valid {
// Update existing task
updateQuery := `
UPDATE tasks SET
title = $1,
description = $2,
status = $3,
priority = $4,
labels = $5,
tech_stack = $6,
external_updated_at = $7,
updated_at = NOW()
WHERE id = $8
`
_, err = m.db.Exec(ctx, updateQuery,
issue.Title,
issue.Body,
status,
priority,
labelsJSON,
techStackJSON,
issue.UpdatedAt,
existingTaskID.String,
)
if err != nil {
return "", false, fmt.Errorf("failed to update task: %w", err)
}
return existingTaskID.String, false, nil
} else {
// Create new task
var taskID string
insertQuery := `
INSERT INTO tasks (
external_id, external_url, source_type, source_config,
title, description, status, priority,
repository, repository_id, labels, tech_stack,
external_created_at, external_updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING id
`
// Prepare source config
sourceConfig := map[string]interface{}{
"repository_id": repo.ID,
"issue_number": issue.Number,
"issue_id": issue.ID,
}
sourceConfigJSON, _ := json.Marshal(sourceConfig)
err = m.db.QueryRow(ctx, insertQuery,
strconv.FormatInt(issue.Number, 10), // external_id
issue.HTMLURL, // external_url
repo.SourceType, // source_type
sourceConfigJSON, // source_config
issue.Title, // title
issue.Body, // description
status, // status
priority, // priority
repo.FullName, // repository
repo.ID, // repository_id
labelsJSON, // labels
techStackJSON, // tech_stack
issue.CreatedAt, // external_created_at
issue.UpdatedAt, // external_updated_at
).Scan(&taskID)
if err != nil {
return "", false, fmt.Errorf("failed to create task: %w", err)
}
return taskID, true, nil
}
}
// hasChorusLabels checks if an issue has any of the required CHORUS labels
func (m *Monitor) hasChorusLabels(issue gitea.Issue, requiredLabels []string) bool {
if len(requiredLabels) == 0 {
return true // If no specific labels required, all issues qualify
}
issueLabels := make(map[string]bool)
for _, label := range issue.Labels {
issueLabels[strings.ToLower(label.Name)] = true
}
for _, required := range requiredLabels {
if issueLabels[strings.ToLower(required)] {
return true
}
}
return false
}
// extractPriorityFromLabels determines priority from issue labels
func (m *Monitor) extractPriorityFromLabels(labels []gitea.Label) string {
priorities := map[string]string{
"critical": "critical",
"urgent": "critical",
"high": "high",
"medium": "medium",
"low": "low",
"minor": "low",
}
for _, label := range labels {
if priority, exists := priorities[strings.ToLower(label.Name)]; exists {
return priority
}
}
return "medium" // Default priority
}
// extractTechStackFromIssue extracts technology stack from issue labels and content
func (m *Monitor) extractTechStackFromIssue(issue gitea.Issue) []string {
techStack := make(map[string]bool)
// Extract from labels
techLabels := []string{
"go", "golang", "javascript", "typescript", "python", "rust", "java",
"react", "vue", "angular", "node.js", "express", "fastapi", "gin",
"postgresql", "mysql", "mongodb", "redis", "docker", "kubernetes",
"api", "frontend", "backend", "database", "devops", "ci/cd",
}
for _, label := range issue.Labels {
labelName := strings.ToLower(label.Name)
for _, tech := range techLabels {
if strings.Contains(labelName, tech) {
techStack[tech] = true
}
}
}
// Convert map to slice
result := make([]string, 0, len(techStack))
for tech := range techStack {
result = append(result, tech)
}
return result
}
// RepositoryConfig represents a monitored repository configuration
type RepositoryConfig struct {
ID string `db:"id"`
Name string `db:"name"`
Owner string `db:"owner"`
FullName string `db:"full_name"`
URL string `db:"url"`
SourceType string `db:"source_type"`
MonitorIssues bool `db:"monitor_issues"`
EnableChorusIntegration bool `db:"enable_chorus_integration"`
ChorusTaskLabels []string `db:"chorus_task_labels"`
LastSync *time.Time `db:"last_sync_at"`
LastIssueSync *time.Time `db:"last_issue_sync"`
SyncStatus string `db:"sync_status"`
}
// getMonitoredRepositories retrieves all repositories that should be monitored
func (m *Monitor) getMonitoredRepositories(ctx context.Context) ([]RepositoryConfig, error) {
query := `
SELECT id, name, owner, full_name, url, source_type, monitor_issues,
enable_chorus_integration, chorus_task_labels, last_sync_at,
last_issue_sync, sync_status
FROM repositories
WHERE monitor_issues = true AND sync_status != 'disabled'
ORDER BY last_sync_at ASC NULLS FIRST
`
rows, err := m.db.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query repositories: %w", err)
}
defer rows.Close()
var repos []RepositoryConfig
for rows.Next() {
var repo RepositoryConfig
var chorusLabelsJSON []byte
err := rows.Scan(
&repo.ID, &repo.Name, &repo.Owner, &repo.FullName, &repo.URL,
&repo.SourceType, &repo.MonitorIssues, &repo.EnableChorusIntegration,
&chorusLabelsJSON, &repo.LastSync, &repo.LastIssueSync, &repo.SyncStatus,
)
if err != nil {
log.Error().Err(err).Msg("Failed to scan repository row")
continue
}
// Parse CHORUS task labels
if err := json.Unmarshal(chorusLabelsJSON, &repo.ChorusTaskLabels); err != nil {
log.Error().Err(err).Str("repository", repo.FullName).Msg("Failed to parse CHORUS task labels")
repo.ChorusTaskLabels = []string{"bzzz-task", "chorus-task"} // Default labels
}
repos = append(repos, repo)
}
return repos, nil
}
// updateRepositoryStatus updates the sync status of a repository
func (m *Monitor) updateRepositoryStatus(ctx context.Context, repoID, status string, err error) error {
var errorMsg sql.NullString
if err != nil {
errorMsg.String = err.Error()
errorMsg.Valid = true
}
query := `
UPDATE repositories
SET sync_status = $1, sync_error = $2, updated_at = NOW()
WHERE id = $3
`
_, dbErr := m.db.Exec(ctx, query, status, errorMsg, repoID)
if dbErr != nil {
return fmt.Errorf("failed to update repository status: %w", dbErr)
}
return nil
}
// updateRepositorySyncInfo updates repository sync information
func (m *Monitor) updateRepositorySyncInfo(ctx context.Context, repoID string, syncTime time.Time, created, updated int) error {
query := `
UPDATE repositories
SET last_sync_at = $1, last_issue_sync = $1,
total_tasks_created = total_tasks_created + $2,
updated_at = NOW()
WHERE id = $3
`
_, err := m.db.Exec(ctx, query, syncTime, created, repoID)
if err != nil {
return fmt.Errorf("failed to update repository sync info: %w", err)
}
return nil
}
// logSyncSuccess logs a successful sync operation
func (m *Monitor) logSyncSuccess(ctx context.Context, repoID, operation string, duration time.Duration, processed, created, updated int) {
query := `
INSERT INTO repository_sync_logs (
repository_id, sync_type, operation, status, message,
items_processed, items_created, items_updated, duration_ms
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
message := fmt.Sprintf("Processed %d items, created %d tasks, updated %d tasks in %v", processed, created, updated, duration)
_, err := m.db.Exec(ctx, query,
repoID, "full_sync", operation, "success", message,
processed, created, updated, duration.Milliseconds(),
)
if err != nil {
log.Error().Err(err).Msg("Failed to log sync success")
}
}
// logSyncError logs a sync error
func (m *Monitor) logSyncError(ctx context.Context, repoID, operation, errorMsg string) {
query := `
INSERT INTO repository_sync_logs (
repository_id, sync_type, operation, status, message, error_details
) VALUES ($1, $2, $3, $4, $5, $6)
`
errorDetails := map[string]interface{}{
"error": errorMsg,
"timestamp": time.Now().Format(time.RFC3339),
}
errorDetailsJSON, _ := json.Marshal(errorDetails)
_, err := m.db.Exec(ctx, query,
repoID, "full_sync", operation, "error", errorMsg, errorDetailsJSON,
)
if err != nil {
log.Error().Err(err).Msg("Failed to log sync error")
}
}
// SyncRepository manually syncs a specific repository by ID
func (m *Monitor) SyncRepository(ctx context.Context, repoID string) error {
log.Info().Str("repository_id", repoID).Msg("Manual repository sync requested")
// Get repository configuration
repo, err := m.getRepositoryByID(ctx, repoID)
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
// Sync the repository
m.syncRepository(ctx, *repo)
return nil
}
// getRepositoryByID retrieves a specific repository configuration by ID
func (m *Monitor) getRepositoryByID(ctx context.Context, repoID string) (*RepositoryConfig, error) {
query := `
SELECT id, name, owner, full_name, url, source_type, monitor_issues,
enable_chorus_integration, chorus_task_labels, last_sync_at,
last_issue_sync, sync_status
FROM repositories
WHERE id = $1
`
var repo RepositoryConfig
var chorusLabelsJSON []byte
err := m.db.QueryRow(ctx, query, repoID).Scan(
&repo.ID, &repo.Name, &repo.Owner, &repo.FullName, &repo.URL,
&repo.SourceType, &repo.MonitorIssues, &repo.EnableChorusIntegration,
&chorusLabelsJSON, &repo.LastSync, &repo.LastIssueSync, &repo.SyncStatus,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("repository not found: %s", repoID)
}
return nil, fmt.Errorf("failed to query repository: %w", err)
}
// Parse CHORUS task labels
if err := json.Unmarshal(chorusLabelsJSON, &repo.ChorusTaskLabels); err != nil {
log.Error().Err(err).Str("repository", repo.FullName).Msg("Failed to parse CHORUS task labels")
repo.ChorusTaskLabels = []string{"bzzz-task", "chorus-task"} // Default labels
}
return &repo, nil
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/chorus-services/whoosh/internal/config" "github.com/chorus-services/whoosh/internal/config"
"github.com/chorus-services/whoosh/internal/database" "github.com/chorus-services/whoosh/internal/database"
"github.com/chorus-services/whoosh/internal/gitea" "github.com/chorus-services/whoosh/internal/gitea"
"github.com/chorus-services/whoosh/internal/monitor"
"github.com/chorus-services/whoosh/internal/p2p" "github.com/chorus-services/whoosh/internal/p2p"
"github.com/chorus-services/whoosh/internal/tasks" "github.com/chorus-services/whoosh/internal/tasks"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -38,6 +39,7 @@ type Server struct {
teamComposer *composer.Service teamComposer *composer.Service
taskService *tasks.Service taskService *tasks.Service
giteaIntegration *tasks.GiteaIntegration giteaIntegration *tasks.GiteaIntegration
repoMonitor *monitor.Monitor
} }
func NewServer(cfg *config.Config, db *database.DB) (*Server, error) { func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
@@ -45,6 +47,9 @@ func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
taskService := tasks.NewService(db.Pool) taskService := tasks.NewService(db.Pool)
giteaIntegration := tasks.NewGiteaIntegration(taskService, gitea.NewClient(cfg.GITEA), nil) giteaIntegration := tasks.NewGiteaIntegration(taskService, gitea.NewClient(cfg.GITEA), nil)
// Initialize repository monitor
repoMonitor := monitor.NewMonitor(db.Pool, cfg.GITEA)
s := &Server{ s := &Server{
config: cfg, config: cfg,
db: db, db: db,
@@ -54,6 +59,7 @@ func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
teamComposer: composer.NewService(db.Pool, nil), // Use default config teamComposer: composer.NewService(db.Pool, nil), // Use default config
taskService: taskService, taskService: taskService,
giteaIntegration: giteaIntegration, giteaIntegration: giteaIntegration,
repoMonitor: repoMonitor,
} }
// Initialize BACKBEAT integration if enabled // Initialize BACKBEAT integration if enabled
@@ -173,6 +179,7 @@ func (s *Server) setupRoutes() {
r.Put("/{repoID}", s.updateRepositoryHandler) r.Put("/{repoID}", s.updateRepositoryHandler)
r.Delete("/{repoID}", s.deleteRepositoryHandler) r.Delete("/{repoID}", s.deleteRepositoryHandler)
r.Post("/{repoID}/sync", s.syncRepositoryHandler) r.Post("/{repoID}/sync", s.syncRepositoryHandler)
r.Post("/{repoID}/ensure-labels", s.ensureRepositoryLabelsHandler)
r.Get("/{repoID}/logs", s.getRepositorySyncLogsHandler) r.Get("/{repoID}/logs", s.getRepositorySyncLogsHandler)
}) })
@@ -199,6 +206,16 @@ func (s *Server) Start(ctx context.Context) error {
return fmt.Errorf("failed to start P2P discovery: %w", err) return fmt.Errorf("failed to start P2P discovery: %w", err)
} }
// Start repository monitoring service
if s.repoMonitor != nil {
go func() {
if err := s.repoMonitor.Start(ctx); err != nil && err != context.Canceled {
log.Error().Err(err).Msg("Repository monitoring service failed")
}
}()
log.Info().Msg("🔍 Repository monitoring service started")
}
log.Info(). log.Info().
Str("addr", s.httpServer.Addr). Str("addr", s.httpServer.Addr).
Msg("HTTP server starting") Msg("HTTP server starting")
@@ -225,6 +242,12 @@ func (s *Server) Shutdown(ctx context.Context) error {
log.Error().Err(err).Msg("Failed to stop P2P discovery service") log.Error().Err(err).Msg("Failed to stop P2P discovery service")
} }
// Stop repository monitoring service
if s.repoMonitor != nil {
s.repoMonitor.Stop()
log.Info().Msg("🛑 Repository monitoring service stopped")
}
if err := s.httpServer.Shutdown(ctx); err != nil { if err := s.httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("server shutdown failed: %w", err) return fmt.Errorf("server shutdown failed: %w", err)
} }
@@ -2535,7 +2558,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
'<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">' + '<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">' +
'<div style="flex: 1;">' + '<div style="flex: 1;">' +
'<div style="display: flex; align-items: center; margin-bottom: 8px;">' + '<div style="display: flex; align-items: center; margin-bottom: 8px;">' +
'<h4 style="margin: 0; color: #2d3748;">' + repo.full_name + '</h4>' + '<h4 style="margin: 0; color: #2d3748;"><a href="' + repo.url + '" target="_blank" style="color: #2d3748; text-decoration: none; cursor: pointer;" onmouseover="this.style.color=\'#4299e1\'" onmouseout="this.style.color=\'#2d3748\'">' + repo.full_name + '</a></h4>' +
'<span style="margin-left: 12px; padding: 2px 8px; background: ' + getStatusColor(repo.sync_status) + '; color: white; border-radius: 12px; font-size: 12px; font-weight: 500;">' + '<span style="margin-left: 12px; padding: 2px 8px; background: ' + getStatusColor(repo.sync_status) + '; color: white; border-radius: 12px; font-size: 12px; font-weight: 500;">' +
repo.sync_status + repo.sync_status +
'</span>' + '</span>' +
@@ -2553,6 +2576,9 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
'<button onclick="syncRepository(\'' + repo.id + '\')" style="background: #4299e1; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' + '<button onclick="syncRepository(\'' + repo.id + '\')" style="background: #4299e1; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
'Sync' + 'Sync' +
'</button>' + '</button>' +
'<button onclick="ensureLabels(\'' + repo.id + '\')" style="background: #38a169; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
'Labels' +
'</button>' +
'<button onclick="editRepository(\'' + repo.id + '\')" style="background: #ed8936; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' + '<button onclick="editRepository(\'' + repo.id + '\')" style="background: #ed8936; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
'Edit' + 'Edit' +
'</button>' + '</button>' +
@@ -2591,9 +2617,183 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
}); });
} }
function ensureLabels(repoId) {
fetch('/api/v1/repositories/' + repoId + '/ensure-labels', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert('Error ensuring labels: ' + data.error);
} else {
alert('Labels ensured successfully for ' + data.owner + '/' + data.name + '\\n\\nRequired labels created:\\n• bzzz-task\\n• whoosh-monitored\\n• priority-high\\n• priority-medium\\n• priority-low');
}
})
.catch(error => {
console.error('Error ensuring labels:', error);
alert('Error ensuring labels');
});
}
function editRepository(repoId) { function editRepository(repoId) {
// For MVP, just show an alert. In production, this would open an edit form // Fetch repository details first
alert('Edit functionality will be implemented. Repository ID: ' + repoId); fetch('/api/v1/repositories/' + repoId)
.then(response => response.json())
.then(repo => {
showEditModal(repo);
})
.catch(error => {
console.error('Error fetching repository:', error);
alert('Error fetching repository details');
});
}
function showEditModal(repo) {
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; ' +
'background: rgba(0,0,0,0.5); display: flex; align-items: center; ' +
'justify-content: center; z-index: 1000;';
// Create modal content
const modal = document.createElement('div');
modal.style.cssText = 'background: white; border-radius: 8px; padding: 24px; ' +
'max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;';
modal.innerHTML =
'<h3 style="margin: 0 0 20px 0; color: #2d3748;">Edit Repository</h3>' +
'<div style="margin-bottom: 16px;">' +
'<strong>' + repo.full_name + '</strong>' +
'<div style="font-size: 12px; color: #718096;">ID: ' + repo.id + '</div>' +
'</div>' +
'<form id="editRepoForm">' +
'<div style="margin-bottom: 16px;">' +
'<label style="display: block; margin-bottom: 4px; font-weight: bold;">Description:</label>' +
'<input type="text" id="description" value="' + (repo.description || '') + '" ' +
'style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">' +
'</div>' +
'<div style="margin-bottom: 16px;">' +
'<label style="display: block; margin-bottom: 4px; font-weight: bold;">Default Branch:</label>' +
'<input type="text" id="defaultBranch" value="' + (repo.default_branch || 'main') + '" ' +
'style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">' +
'</div>' +
'<div style="margin-bottom: 16px;">' +
'<label style="display: block; margin-bottom: 4px; font-weight: bold;">Language:</label>' +
'<input type="text" id="language" value="' + (repo.language || '') + '" ' +
'style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">' +
'</div>' +
'<div style="margin-bottom: 16px;">' +
'<h4 style="margin: 0 0 8px 0;">Monitoring Options:</h4>' +
'<div style="margin-bottom: 8px;">' +
'<label style="display: flex; align-items: center;">' +
'<input type="checkbox" id="monitorIssues" ' + (repo.monitor_issues ? 'checked' : '') + ' style="margin-right: 8px;">' +
'Monitor Issues' +
'</label>' +
'</div>' +
'<div style="margin-bottom: 8px;">' +
'<label style="display: flex; align-items: center;">' +
'<input type="checkbox" id="monitorPRs" ' + (repo.monitor_pull_requests ? 'checked' : '') + ' style="margin-right: 8px;">' +
'Monitor Pull Requests' +
'</label>' +
'</div>' +
'<div style="margin-bottom: 8px;">' +
'<label style="display: flex; align-items: center;">' +
'<input type="checkbox" id="monitorReleases" ' + (repo.monitor_releases ? 'checked' : '') + ' style="margin-right: 8px;">' +
'Monitor Releases' +
'</label>' +
'</div>' +
'</div>' +
'<div style="margin-bottom: 16px;">' +
'<h4 style="margin: 0 0 8px 0;">CHORUS Integration:</h4>' +
'<div style="margin-bottom: 8px;">' +
'<label style="display: flex; align-items: center;">' +
'<input type="checkbox" id="enableChorus" ' + (repo.enable_chorus_integration ? 'checked' : '') + ' style="margin-right: 8px;">' +
'Enable CHORUS Integration' +
'</label>' +
'</div>' +
'<div style="margin-bottom: 8px;">' +
'<label style="display: flex; align-items: center;">' +
'<input type="checkbox" id="autoAssignTeams" ' + (repo.auto_assign_teams ? 'checked' : '') + ' style="margin-right: 8px;">' +
'Auto-assign Teams' +
'</label>' +
'</div>' +
'</div>' +
'<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px;">' +
'<button type="button" onclick="closeEditModal()" ' +
'style="background: #e2e8f0; color: #4a5568; border: none; padding: 10px 16px; border-radius: 4px; cursor: pointer;">' +
'Cancel' +
'</button>' +
'<button type="submit" ' +
'style="background: #4299e1; color: white; border: none; padding: 10px 16px; border-radius: 4px; cursor: pointer;">' +
'Save Changes' +
'</button>' +
'</div>' +
'</form>';
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Store modal reference globally so we can close it
window.currentEditModal = overlay;
window.currentRepoId = repo.id;
// Handle form submission
document.getElementById('editRepoForm').addEventListener('submit', function(e) {
e.preventDefault();
saveRepositoryChanges();
});
// Close modal on overlay click
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeEditModal();
}
});
}
function closeEditModal() {
if (window.currentEditModal) {
document.body.removeChild(window.currentEditModal);
window.currentEditModal = null;
window.currentRepoId = null;
}
}
function saveRepositoryChanges() {
const formData = {
description: document.getElementById('description').value.trim() || null,
default_branch: document.getElementById('defaultBranch').value.trim() || null,
language: document.getElementById('language').value.trim() || null,
monitor_issues: document.getElementById('monitorIssues').checked,
monitor_pull_requests: document.getElementById('monitorPRs').checked,
monitor_releases: document.getElementById('monitorReleases').checked,
enable_chorus_integration: document.getElementById('enableChorus').checked,
auto_assign_teams: document.getElementById('autoAssignTeams').checked
};
fetch('/api/v1/repositories/' + window.currentRepoId, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
alert('Repository updated successfully!');
closeEditModal();
loadRepositories(); // Reload the list to show changes
})
.catch(error => {
console.error('Error updating repository:', error);
alert('Error updating repository');
});
} }
function deleteRepository(repoId, fullName) { function deleteRepository(repoId, fullName) {
@@ -2879,6 +3079,26 @@ func (s *Server) createRepositoryHandler(w http.ResponseWriter, r *http.Request)
return return
} }
// Automatically create required labels in the Gitea repository
if req.SourceType == "gitea" && s.repoMonitor != nil && s.repoMonitor.GetGiteaClient() != nil {
log.Info().
Str("repository", fullName).
Msg("Creating required labels in Gitea repository")
err := s.repoMonitor.GetGiteaClient().EnsureRequiredLabels(context.Background(), req.Owner, req.Name)
if err != nil {
log.Warn().
Err(err).
Str("repository", fullName).
Msg("Failed to create labels in Gitea repository - repository monitoring will still work")
// Don't fail the entire request if label creation fails
} else {
log.Info().
Str("repository", fullName).
Msg("Successfully created required labels in Gitea repository")
}
}
render.Status(r, http.StatusCreated) render.Status(r, http.StatusCreated)
render.JSON(w, r, map[string]interface{}{ render.JSON(w, r, map[string]interface{}{
"id": id, "id": id,
@@ -3088,13 +3308,85 @@ func (s *Server) syncRepositoryHandler(w http.ResponseWriter, r *http.Request) {
log.Info().Str("repository_id", repoID).Msg("Manual repository sync triggered") log.Info().Str("repository_id", repoID).Msg("Manual repository sync triggered")
// TODO: Implement repository sync logic if s.repoMonitor == nil {
// This would trigger the Gitea issue monitoring service render.Status(r, http.StatusServiceUnavailable)
render.JSON(w, r, map[string]string{"error": "repository monitoring service not available"})
return
}
// Trigger repository sync in background
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := s.repoMonitor.SyncRepository(ctx, repoID); err != nil {
log.Error().
Err(err).
Str("repository_id", repoID).
Msg("Manual repository sync failed")
}
}()
render.JSON(w, r, map[string]interface{}{ render.JSON(w, r, map[string]interface{}{
"message": "Repository sync triggered", "message": "Repository sync triggered",
"repository_id": repoID, "repository_id": repoID,
"status": "pending", "status": "started",
})
}
// ensureRepositoryLabelsHandler ensures required labels exist in the Gitea repository
func (s *Server) ensureRepositoryLabelsHandler(w http.ResponseWriter, r *http.Request) {
repoID := chi.URLParam(r, "repoID")
log.Info().Str("repository_id", repoID).Msg("Ensuring repository labels")
if s.repoMonitor == nil || s.repoMonitor.GetGiteaClient() == nil {
render.Status(r, http.StatusServiceUnavailable)
render.JSON(w, r, map[string]string{"error": "repository monitoring service not available"})
return
}
// Get repository details first
query := "SELECT owner, name FROM repositories WHERE id = $1"
var owner, name string
err := s.db.Pool.QueryRow(context.Background(), query, repoID).Scan(&owner, &name)
if err != nil {
if err.Error() == "no rows in result set" {
render.Status(r, http.StatusNotFound)
render.JSON(w, r, map[string]string{"error": "repository not found"})
return
}
log.Error().Err(err).Msg("Failed to get repository")
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": "failed to get repository"})
return
}
// Ensure required labels exist
err = s.repoMonitor.GetGiteaClient().EnsureRequiredLabels(context.Background(), owner, name)
if err != nil {
log.Error().
Err(err).
Str("repository_id", repoID).
Str("owner", owner).
Str("name", name).
Msg("Failed to ensure repository labels")
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": "failed to create labels: " + err.Error()})
return
}
log.Info().
Str("repository_id", repoID).
Str("owner", owner).
Str("name", name).
Msg("Successfully ensured repository labels")
render.JSON(w, r, map[string]interface{}{
"message": "Repository labels ensured successfully",
"repository_id": repoID,
"owner": owner,
"name": name,
}) })
} }