Fix critical issues in WHOOSH Gitea issue monitoring and task creation

This commit resolves multiple blocking issues that were preventing WHOOSH from
properly detecting and converting bzzz-task labeled issues from Gitea:

## Issues Fixed:

1. **JSON Parsing Error**: Gitea API returns repository owner as string in issue
   responses, but code expected User object. Added IssueRepository struct to
   handle this API response format difference.

2. **Database Error Handling**: Code was using database/sql.ErrNoRows but
   system uses pgx driver. Updated imports and error constants to use
   pgx.ErrNoRows consistently.

3. **NULL Value Scanning**: Database fields (repository, project_id,
   estimated_hours, complexity_score) can be NULL but Go structs used
   non-pointer types. Added proper NULL handling with pointer scanning
   and safe conversion.

## Results:
-  WHOOSH now successfully detects bzzz-task labeled issues
-  Task creation pipeline working end-to-end
-  Tasks API functioning properly
-  First bzzz-task converted: "Logic around registered agents faulty"

The core issue monitoring workflow is now fully operational and ready for
CHORUS integration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code
2025-09-10 12:57:11 +10:00
parent 4173c0c8c8
commit b5c0deb6bc
3 changed files with 58 additions and 30 deletions

View File

@@ -22,19 +22,19 @@ type Client struct {
// Issue represents a Gitea issue // Issue represents a Gitea issue
type Issue struct { type Issue struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Number int64 `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"`
Labels []Label `json:"labels"` Labels []Label `json:"labels"`
Assignees []User `json:"assignees"` Assignees []User `json:"assignees"`
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"` ClosedAt *time.Time `json:"closed_at"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
User User `json:"user"` User User `json:"user"`
Repository Repository `json:"repository,omitempty"` Repository IssueRepository `json:"repository,omitempty"`
} }
// Label represents a Gitea issue label // Label represents a Gitea issue label
@@ -68,6 +68,14 @@ type Repository struct {
Language string `json:"language"` Language string `json:"language"`
} }
// IssueRepository represents the simplified repository info in issue responses
type IssueRepository struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Owner string `json:"owner"` // Note: This is a string, not a User object
}
// NewClient creates a new Gitea API client // NewClient creates a new Gitea API client
func NewClient(cfg config.GITEAConfig) *Client { func NewClient(cfg config.GITEAConfig) *Client {
token := cfg.Token token := cfg.Token
@@ -167,10 +175,10 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
// Set repository information on each issue for context // Set repository information on each issue for context
for i := range issues { for i := range issues {
issues[i].Repository = Repository{ issues[i].Repository = IssueRepository{
Name: repo, Name: repo,
FullName: fmt.Sprintf("%s/%s", owner, repo), FullName: fmt.Sprintf("%s/%s", owner, repo),
Owner: User{Login: owner}, Owner: owner, // Now a string instead of User object
} }
} }
@@ -193,10 +201,10 @@ func (c *Client) GetIssue(ctx context.Context, owner, repo string, issueNumber i
} }
// Set repository information // Set repository information
issue.Repository = Repository{ issue.Repository = IssueRepository{
Name: repo, Name: repo,
FullName: fmt.Sprintf("%s/%s", owner, repo), FullName: fmt.Sprintf("%s/%s", owner, repo),
Owner: User{Login: owner}, Owner: owner, // Now a string instead of User object
} }
return &issue, nil return &issue, nil

View File

@@ -2,7 +2,6 @@ package monitor
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
@@ -11,6 +10,7 @@ import (
"github.com/chorus-services/whoosh/internal/config" "github.com/chorus-services/whoosh/internal/config"
"github.com/chorus-services/whoosh/internal/gitea" "github.com/chorus-services/whoosh/internal/gitea"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -202,11 +202,14 @@ func (m *Monitor) syncRepository(ctx context.Context, repo RepositoryConfig) {
// createOrUpdateTask creates a new task or updates an existing one from a Gitea issue // 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) { func (m *Monitor) createOrUpdateTask(ctx context.Context, repo RepositoryConfig, issue gitea.Issue) (string, bool, error) {
// Check if task already exists // Check if task already exists
var existingTaskID sql.NullString var existingTaskID string
query := `SELECT id FROM tasks WHERE external_id = $1 AND source_type = $2` 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) err := m.db.QueryRow(ctx, query, strconv.FormatInt(issue.Number, 10), repo.SourceType).Scan(&existingTaskID)
if err != nil && err != sql.ErrNoRows { taskExists := err == nil
// Handle errors other than "no rows found"
if err != nil && err != pgx.ErrNoRows {
return "", false, fmt.Errorf("failed to check existing task: %w", err) return "", false, fmt.Errorf("failed to check existing task: %w", err)
} }
@@ -230,8 +233,8 @@ func (m *Monitor) createOrUpdateTask(ctx context.Context, repo RepositoryConfig,
techStack := m.extractTechStackFromIssue(issue) techStack := m.extractTechStackFromIssue(issue)
techStackJSON, _ := json.Marshal(techStack) techStackJSON, _ := json.Marshal(techStack)
if existingTaskID.Valid { if taskExists {
// Update existing task // Task exists - update it
updateQuery := ` updateQuery := `
UPDATE tasks SET UPDATE tasks SET
title = $1, title = $1,
@@ -253,14 +256,14 @@ func (m *Monitor) createOrUpdateTask(ctx context.Context, repo RepositoryConfig,
labelsJSON, labelsJSON,
techStackJSON, techStackJSON,
issue.UpdatedAt, issue.UpdatedAt,
existingTaskID.String, existingTaskID,
) )
if err != nil { if err != nil {
return "", false, fmt.Errorf("failed to update task: %w", err) return "", false, fmt.Errorf("failed to update task: %w", err)
} }
return existingTaskID.String, false, nil return existingTaskID, false, nil
} else { } else {
// Create new task // Create new task
var taskID string var taskID string
@@ -440,10 +443,10 @@ func (m *Monitor) getMonitoredRepositories(ctx context.Context) ([]RepositoryCon
// updateRepositoryStatus updates the sync status of a repository // updateRepositoryStatus updates the sync status of a repository
func (m *Monitor) updateRepositoryStatus(ctx context.Context, repoID, status string, err error) error { func (m *Monitor) updateRepositoryStatus(ctx context.Context, repoID, status string, err error) error {
var errorMsg sql.NullString var errorMsg *string
if err != nil { if err != nil {
errorMsg.String = err.Error() errStr := err.Error()
errorMsg.Valid = true errorMsg = &errStr
} }
query := ` query := `
@@ -555,7 +558,7 @@ func (m *Monitor) getRepositoryByID(ctx context.Context, repoID string) (*Reposi
&chorusLabelsJSON, &repo.LastSync, &repo.LastIssueSync, &repo.SyncStatus, &chorusLabelsJSON, &repo.LastSync, &repo.LastIssueSync, &repo.SyncStatus,
) )
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == pgx.ErrNoRows {
return nil, fmt.Errorf("repository not found: %s", repoID) return nil, fmt.Errorf("repository not found: %s", repoID)
} }
return nil, fmt.Errorf("failed to query repository: %w", err) return nil, fmt.Errorf("failed to query repository: %w", err)

View File

@@ -354,13 +354,16 @@ func (s *Service) UpdateTaskStatus(ctx context.Context, update *TaskStatusUpdate
func (s *Service) scanTask(scanner interface{ Scan(...interface{}) error }) (*Task, error) { func (s *Service) scanTask(scanner interface{ Scan(...interface{}) error }) (*Task, error) {
task := &Task{} task := &Task{}
var sourceConfigJSON, labelsJSON, techStackJSON, requirementsJSON []byte var sourceConfigJSON, labelsJSON, techStackJSON, requirementsJSON []byte
var repository, projectID *string
var estimatedHours *int
var complexityScore *float64
err := scanner.Scan( err := scanner.Scan(
&task.ID, &task.ExternalID, &task.ExternalURL, &task.SourceType, &sourceConfigJSON, &task.ID, &task.ExternalID, &task.ExternalURL, &task.SourceType, &sourceConfigJSON,
&task.Title, &task.Description, &task.Status, &task.Priority, &task.Title, &task.Description, &task.Status, &task.Priority,
&task.AssignedTeamID, &task.AssignedAgentID, &task.AssignedTeamID, &task.AssignedAgentID,
&task.Repository, &task.ProjectID, &labelsJSON, &techStackJSON, &requirementsJSON, &repository, &projectID, &labelsJSON, &techStackJSON, &requirementsJSON,
&task.EstimatedHours, &task.ComplexityScore, &estimatedHours, &complexityScore,
&task.ClaimedAt, &task.StartedAt, &task.CompletedAt, &task.ClaimedAt, &task.StartedAt, &task.CompletedAt,
&task.CreatedAt, &task.UpdatedAt, &task.ExternalCreatedAt, &task.ExternalUpdatedAt, &task.CreatedAt, &task.UpdatedAt, &task.ExternalCreatedAt, &task.ExternalUpdatedAt,
) )
@@ -369,6 +372,20 @@ func (s *Service) scanTask(scanner interface{ Scan(...interface{}) error }) (*Ta
return nil, err return nil, err
} }
// Handle nullable fields
if repository != nil {
task.Repository = *repository
}
if projectID != nil {
task.ProjectID = *projectID
}
if estimatedHours != nil {
task.EstimatedHours = *estimatedHours
}
if complexityScore != nil {
task.ComplexityScore = *complexityScore
}
// Parse JSON fields // Parse JSON fields
if len(sourceConfigJSON) > 0 { if len(sourceConfigJSON) > 0 {
json.Unmarshal(sourceConfigJSON, &task.SourceConfig) json.Unmarshal(sourceConfigJSON, &task.SourceConfig)