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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user