6 Commits

Author SHA1 Message Date
Felipe Cardoso
f0b04d53af test(frontend): update tests for type changes
Update all test files to use correct enum values:
- AgentPanel, AgentStatusIndicator tests
- ProjectHeader, StatusBadge tests
- IssueSummary, IssueTable tests
- StatusBadge, StatusWorkflow tests (issues)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:48:11 +01:00
Felipe Cardoso
35af7daf90 fix(frontend): align project types with backend enums
- Fix ProjectStatus: use 'active' instead of 'in_progress'
- Fix AgentStatus: remove 'active'/'pending'/'error', add 'waiting'
- Fix SprintStatus: add 'in_review'
- Rename IssueSummary to IssueCountSummary
- Update all components to use correct enum values

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:48:02 +01:00
Felipe Cardoso
5fab15a11e fix(frontend): align issue types with backend enums
- Fix IssueStatus: remove 'done', keep 'closed'
- Add IssuePriority 'critical' level
- Add IssueType enum (epic, story, task, bug)
- Update constants, hooks, and mocks to match
- Fix StatusWorkflow component icons

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:52 +01:00
Felipe Cardoso
ab913575e1 feat(frontend): add ErrorBoundary component
Add React ErrorBoundary component for catching and handling
render errors in component trees with fallback UI.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:38 +01:00
Felipe Cardoso
82cb6386a6 fix(backend): regenerate Syndarix migration to match models
Completely rewrote migration 0004 to match current model definitions:
- Added issue_type ENUM (epic, story, task, bug)
- Fixed sprint_status ENUM to include in_review
- Fixed all table columns to match models exactly
- Fixed all indexes and constraints

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:30 +01:00
Felipe Cardoso
2d05035c1d fix(backend): add unique constraint for sprint numbers
Add UniqueConstraint to Sprint model to ensure sprint numbers
are unique within a project, matching the migration specification.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:19 +01:00
24 changed files with 436 additions and 175 deletions

View File

@@ -2,14 +2,14 @@
Revision ID: 0004 Revision ID: 0004
Revises: 0003 Revises: 0003
Create Date: 2025-12-30 Create Date: 2025-12-31
This migration creates the core Syndarix domain tables: This migration creates the core Syndarix domain tables:
- projects: Client engagement projects - projects: Client engagement projects
- agent_types: Agent template configurations - agent_types: Agent template configurations
- agent_instances: Spawned agent instances assigned to projects - agent_instances: Spawned agent instances assigned to projects
- issues: Work items (stories, tasks, bugs)
- sprints: Sprint containers for issues - sprints: Sprint containers for issues
- issues: Work items (epics, stories, tasks, bugs)
""" """
from collections.abc import Sequence from collections.abc import Sequence
@@ -28,7 +28,9 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
"""Create Syndarix domain tables.""" """Create Syndarix domain tables."""
# Create ENUM types first # =========================================================================
# Create ENUM types
# =========================================================================
op.execute( op.execute(
""" """
CREATE TYPE autonomy_level AS ENUM ( CREATE TYPE autonomy_level AS ENUM (
@@ -64,24 +66,24 @@ def upgrade() -> None:
) )
""" """
) )
op.execute(
"""
CREATE TYPE issue_type AS ENUM (
'epic', 'story', 'task', 'bug'
)
"""
)
op.execute( op.execute(
""" """
CREATE TYPE issue_status AS ENUM ( CREATE TYPE issue_status AS ENUM (
'open', 'in_progress', 'in_review', 'closed', 'blocked' 'open', 'in_progress', 'in_review', 'blocked', 'closed'
) )
""" """
) )
op.execute( op.execute(
""" """
CREATE TYPE issue_priority AS ENUM ( CREATE TYPE issue_priority AS ENUM (
'critical', 'high', 'medium', 'low' 'low', 'medium', 'high', 'critical'
)
"""
)
op.execute(
"""
CREATE TYPE external_tracker_type AS ENUM (
'gitea', 'github', 'gitlab', 'jira'
) )
""" """
) )
@@ -95,12 +97,14 @@ def upgrade() -> None:
op.execute( op.execute(
""" """
CREATE TYPE sprint_status AS ENUM ( CREATE TYPE sprint_status AS ENUM (
'planned', 'active', 'completed', 'cancelled' 'planned', 'active', 'in_review', 'completed', 'cancelled'
) )
""" """
) )
# =========================================================================
# Create projects table # Create projects table
# =========================================================================
op.create_table( op.create_table(
"projects", "projects",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
@@ -152,7 +156,10 @@ def upgrade() -> None:
server_default="auto", server_default="auto",
), ),
sa.Column( sa.Column(
"settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}" "settings",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
), ),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column( sa.Column(
@@ -168,11 +175,10 @@ def upgrade() -> None:
server_default=sa.text("now()"), server_default=sa.text("now()"),
), ),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="SET NULL"),
["owner_id"], ["users.id"], ondelete="SET NULL"
),
sa.UniqueConstraint("slug"), sa.UniqueConstraint("slug"),
) )
# Single column indexes
op.create_index("ix_projects_name", "projects", ["name"]) op.create_index("ix_projects_name", "projects", ["name"])
op.create_index("ix_projects_slug", "projects", ["slug"]) op.create_index("ix_projects_slug", "projects", ["slug"])
op.create_index("ix_projects_status", "projects", ["status"]) op.create_index("ix_projects_status", "projects", ["status"])
@@ -180,6 +186,7 @@ def upgrade() -> None:
op.create_index("ix_projects_complexity", "projects", ["complexity"]) op.create_index("ix_projects_complexity", "projects", ["complexity"])
op.create_index("ix_projects_client_mode", "projects", ["client_mode"]) op.create_index("ix_projects_client_mode", "projects", ["client_mode"])
op.create_index("ix_projects_owner_id", "projects", ["owner_id"]) op.create_index("ix_projects_owner_id", "projects", ["owner_id"])
# Composite indexes
op.create_index("ix_projects_slug_status", "projects", ["slug", "status"]) op.create_index("ix_projects_slug_status", "projects", ["slug", "status"])
op.create_index("ix_projects_owner_status", "projects", ["owner_id", "status"]) op.create_index("ix_projects_owner_status", "projects", ["owner_id", "status"])
op.create_index( op.create_index(
@@ -189,13 +196,25 @@ def upgrade() -> None:
"ix_projects_complexity_status", "projects", ["complexity", "status"] "ix_projects_complexity_status", "projects", ["complexity", "status"]
) )
# =========================================================================
# Create agent_types table # Create agent_types table
# =========================================================================
op.create_table( op.create_table(
"agent_types", "agent_types",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(100), nullable=False), sa.Column("name", sa.String(255), nullable=False),
sa.Column("slug", sa.String(100), nullable=False), sa.Column("slug", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True), sa.Column("description", sa.Text(), nullable=True),
# Areas of expertise (e.g., ["python", "fastapi", "databases"])
sa.Column(
"expertise",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="[]",
),
# System prompt defining personality and behavior (required)
sa.Column("personality_prompt", sa.Text(), nullable=False),
# LLM model configuration
sa.Column("primary_model", sa.String(100), nullable=False), sa.Column("primary_model", sa.String(100), nullable=False),
sa.Column( sa.Column(
"fallback_models", "fallback_models",
@@ -203,16 +222,23 @@ def upgrade() -> None:
nullable=False, nullable=False,
server_default="[]", server_default="[]",
), ),
sa.Column("system_prompt", sa.Text(), nullable=True), # Model parameters (temperature, max_tokens, etc.)
sa.Column("personality_prompt", sa.Text(), nullable=True),
sa.Column( sa.Column(
"capabilities", "model_params",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
# MCP servers this agent can connect to
sa.Column(
"mcp_servers",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
nullable=False, nullable=False,
server_default="[]", server_default="[]",
), ),
# Tool permissions configuration
sa.Column( sa.Column(
"default_config", "tool_permissions",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
nullable=False, nullable=False,
server_default="{}", server_default="{}",
@@ -233,12 +259,17 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"), sa.UniqueConstraint("slug"),
) )
# Single column indexes
op.create_index("ix_agent_types_name", "agent_types", ["name"]) op.create_index("ix_agent_types_name", "agent_types", ["name"])
op.create_index("ix_agent_types_slug", "agent_types", ["slug"]) op.create_index("ix_agent_types_slug", "agent_types", ["slug"])
op.create_index("ix_agent_types_is_active", "agent_types", ["is_active"]) op.create_index("ix_agent_types_is_active", "agent_types", ["is_active"])
op.create_index("ix_agent_types_primary_model", "agent_types", ["primary_model"]) # Composite indexes
op.create_index("ix_agent_types_slug_active", "agent_types", ["slug", "is_active"])
op.create_index("ix_agent_types_name_active", "agent_types", ["name", "is_active"])
# =========================================================================
# Create agent_instances table # Create agent_instances table
# =========================================================================
op.create_table( op.create_table(
"agent_instances", "agent_instances",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
@@ -260,17 +291,28 @@ def upgrade() -> None:
server_default="idle", server_default="idle",
), ),
sa.Column("current_task", sa.Text(), nullable=True), sa.Column("current_task", sa.Text(), nullable=True),
# Short-term memory (conversation context, recent decisions)
sa.Column( sa.Column(
"config_overrides", "short_term_memory",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
nullable=False, nullable=False,
server_default="{}", server_default="{}",
), ),
# Reference to long-term memory in vector store
sa.Column("long_term_memory_ref", sa.String(500), nullable=True),
# Session ID for active MCP connections
sa.Column("session_id", sa.String(255), nullable=True),
# Activity tracking
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("terminated_at", sa.DateTime(timezone=True), nullable=True),
# Usage metrics
sa.Column("tasks_completed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("tokens_used", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column( sa.Column(
"metadata", "cost_incurred",
postgresql.JSONB(astext_type=sa.Text()), sa.Numeric(precision=10, scale=4),
nullable=False, nullable=False,
server_default="{}", server_default="0",
), ),
sa.Column( sa.Column(
"created_at", "created_at",
@@ -290,12 +332,21 @@ def upgrade() -> None:
), ),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
) )
# Single column indexes
op.create_index("ix_agent_instances_name", "agent_instances", ["name"]) op.create_index("ix_agent_instances_name", "agent_instances", ["name"])
op.create_index("ix_agent_instances_status", "agent_instances", ["status"]) op.create_index("ix_agent_instances_status", "agent_instances", ["status"])
op.create_index( op.create_index(
"ix_agent_instances_agent_type_id", "agent_instances", ["agent_type_id"] "ix_agent_instances_agent_type_id", "agent_instances", ["agent_type_id"]
) )
op.create_index("ix_agent_instances_project_id", "agent_instances", ["project_id"]) op.create_index("ix_agent_instances_project_id", "agent_instances", ["project_id"])
op.create_index("ix_agent_instances_session_id", "agent_instances", ["session_id"])
op.create_index(
"ix_agent_instances_last_activity_at", "agent_instances", ["last_activity_at"]
)
op.create_index(
"ix_agent_instances_terminated_at", "agent_instances", ["terminated_at"]
)
# Composite indexes
op.create_index( op.create_index(
"ix_agent_instances_project_status", "ix_agent_instances_project_status",
"agent_instances", "agent_instances",
@@ -306,22 +357,30 @@ def upgrade() -> None:
"agent_instances", "agent_instances",
["agent_type_id", "status"], ["agent_type_id", "status"],
) )
op.create_index(
"ix_agent_instances_project_type",
"agent_instances",
["project_id", "agent_type_id"],
)
# =========================================================================
# Create sprints table (before issues for FK reference) # Create sprints table (before issues for FK reference)
# =========================================================================
op.create_table( op.create_table(
"sprints", "sprints",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(100), nullable=False), sa.Column("name", sa.String(255), nullable=False),
sa.Column("number", sa.Integer(), nullable=False), sa.Column("number", sa.Integer(), nullable=False),
sa.Column("goal", sa.Text(), nullable=True), sa.Column("goal", sa.Text(), nullable=True),
sa.Column("start_date", sa.Date(), nullable=True), sa.Column("start_date", sa.Date(), nullable=False),
sa.Column("end_date", sa.Date(), nullable=True), sa.Column("end_date", sa.Date(), nullable=False),
sa.Column( sa.Column(
"status", "status",
sa.Enum( sa.Enum(
"planned", "planned",
"active", "active",
"in_review",
"completed", "completed",
"cancelled", "cancelled",
name="sprint_status", name="sprint_status",
@@ -348,29 +407,53 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.UniqueConstraint("project_id", "number", name="uq_sprint_project_number"), sa.UniqueConstraint("project_id", "number", name="uq_sprint_project_number"),
) )
op.create_index("ix_sprints_name", "sprints", ["name"]) # Single column indexes
op.create_index("ix_sprints_number", "sprints", ["number"])
op.create_index("ix_sprints_status", "sprints", ["status"])
op.create_index("ix_sprints_project_id", "sprints", ["project_id"]) op.create_index("ix_sprints_project_id", "sprints", ["project_id"])
op.create_index("ix_sprints_status", "sprints", ["status"])
op.create_index("ix_sprints_start_date", "sprints", ["start_date"])
op.create_index("ix_sprints_end_date", "sprints", ["end_date"])
# Composite indexes
op.create_index("ix_sprints_project_status", "sprints", ["project_id", "status"]) op.create_index("ix_sprints_project_status", "sprints", ["project_id", "status"])
op.create_index("ix_sprints_project_number", "sprints", ["project_id", "number"])
op.create_index("ix_sprints_date_range", "sprints", ["start_date", "end_date"])
# =========================================================================
# Create issues table # Create issues table
# =========================================================================
op.create_table( op.create_table(
"issues", "issues",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("sprint_id", postgresql.UUID(as_uuid=True), nullable=True), # Parent issue for hierarchy (Epic -> Story -> Task)
sa.Column("assigned_agent_id", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("parent_id", postgresql.UUID(as_uuid=True), nullable=True),
# Issue type (epic, story, task, bug)
sa.Column(
"type",
sa.Enum(
"epic",
"story",
"task",
"bug",
name="issue_type",
create_type=False,
),
nullable=False,
server_default="task",
),
# Reporter (who created this issue)
sa.Column("reporter_id", postgresql.UUID(as_uuid=True), nullable=True),
# Issue content
sa.Column("title", sa.String(500), nullable=False), sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=True), sa.Column("body", sa.Text(), nullable=False, server_default=""),
# Status and priority
sa.Column( sa.Column(
"status", "status",
sa.Enum( sa.Enum(
"open", "open",
"in_progress", "in_progress",
"in_review", "in_review",
"closed",
"blocked", "blocked",
"closed",
name="issue_status", name="issue_status",
create_type=False, create_type=False,
), ),
@@ -380,33 +463,37 @@ def upgrade() -> None:
sa.Column( sa.Column(
"priority", "priority",
sa.Enum( sa.Enum(
"critical", "high", "medium", "low", name="issue_priority", create_type=False "low",
"medium",
"high",
"critical",
name="issue_priority",
create_type=False,
), ),
nullable=False, nullable=False,
server_default="medium", server_default="medium",
), ),
sa.Column("story_points", sa.Integer(), nullable=True), # Labels for categorization
sa.Column( sa.Column(
"labels", "labels",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
nullable=False, nullable=False,
server_default="[]", server_default="[]",
), ),
sa.Column( # Assignment - agent or human (mutually exclusive)
"external_tracker", sa.Column("assigned_agent_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Enum( sa.Column("human_assignee", sa.String(255), nullable=True),
"gitea", # Sprint association
"github", sa.Column("sprint_id", postgresql.UUID(as_uuid=True), nullable=True),
"gitlab", # Estimation
"jira", sa.Column("story_points", sa.Integer(), nullable=True),
name="external_tracker_type", sa.Column("due_date", sa.Date(), nullable=True),
create_type=False, # External tracker integration (String for flexibility)
), sa.Column("external_tracker_type", sa.String(50), nullable=True),
nullable=True, sa.Column("external_issue_id", sa.String(255), nullable=True),
), sa.Column("remote_url", sa.String(1000), nullable=True),
sa.Column("external_id", sa.String(255), nullable=True), sa.Column("external_issue_number", sa.Integer(), nullable=True),
sa.Column("external_url", sa.String(2048), nullable=True), # Sync status
sa.Column("external_number", sa.Integer(), nullable=True),
sa.Column( sa.Column(
"sync_status", "sync_status",
sa.Enum( sa.Enum(
@@ -417,15 +504,13 @@ def upgrade() -> None:
name="sync_status", name="sync_status",
create_type=False, create_type=False,
), ),
nullable=True, nullable=False,
server_default="synced",
), ),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column( sa.Column("external_updated_at", sa.DateTime(timezone=True), nullable=True),
"metadata", # Lifecycle
postgresql.JSONB(astext_type=sa.Text()), sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
nullable=False,
server_default="{}",
),
sa.Column( sa.Column(
"created_at", "created_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
@@ -440,29 +525,43 @@ def upgrade() -> None:
), ),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["parent_id"], ["issues.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["sprint_id"], ["sprints.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["sprint_id"], ["sprints.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["assigned_agent_id"], ["agent_instances.id"], ondelete="SET NULL" ["assigned_agent_id"], ["agent_instances.id"], ondelete="SET NULL"
), ),
) )
op.create_index("ix_issues_title", "issues", ["title"]) # Single column indexes
op.create_index("ix_issues_project_id", "issues", ["project_id"])
op.create_index("ix_issues_parent_id", "issues", ["parent_id"])
op.create_index("ix_issues_type", "issues", ["type"])
op.create_index("ix_issues_reporter_id", "issues", ["reporter_id"])
op.create_index("ix_issues_status", "issues", ["status"]) op.create_index("ix_issues_status", "issues", ["status"])
op.create_index("ix_issues_priority", "issues", ["priority"]) op.create_index("ix_issues_priority", "issues", ["priority"])
op.create_index("ix_issues_project_id", "issues", ["project_id"])
op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"])
op.create_index("ix_issues_assigned_agent_id", "issues", ["assigned_agent_id"]) op.create_index("ix_issues_assigned_agent_id", "issues", ["assigned_agent_id"])
op.create_index("ix_issues_external_tracker", "issues", ["external_tracker"]) op.create_index("ix_issues_human_assignee", "issues", ["human_assignee"])
op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"])
op.create_index("ix_issues_due_date", "issues", ["due_date"])
op.create_index("ix_issues_external_tracker_type", "issues", ["external_tracker_type"])
op.create_index("ix_issues_sync_status", "issues", ["sync_status"]) op.create_index("ix_issues_sync_status", "issues", ["sync_status"])
op.create_index("ix_issues_closed_at", "issues", ["closed_at"])
# Composite indexes
op.create_index("ix_issues_project_status", "issues", ["project_id", "status"]) op.create_index("ix_issues_project_status", "issues", ["project_id", "status"])
op.create_index("ix_issues_project_priority", "issues", ["project_id", "priority"])
op.create_index("ix_issues_project_sprint", "issues", ["project_id", "sprint_id"])
op.create_index("ix_issues_project_type", "issues", ["project_id", "type"])
op.create_index("ix_issues_project_agent", "issues", ["project_id", "assigned_agent_id"])
op.create_index( op.create_index(
"ix_issues_project_status_priority", "ix_issues_project_status_priority",
"issues", "issues",
["project_id", "status", "priority"], ["project_id", "status", "priority"],
) )
op.create_index( op.create_index(
"ix_issues_external", "ix_issues_external_tracker_id",
"issues", "issues",
["project_id", "external_tracker", "external_id"], ["external_tracker_type", "external_issue_id"],
) )
@@ -478,9 +577,9 @@ def downgrade() -> None:
# Drop ENUM types # Drop ENUM types
op.execute("DROP TYPE IF EXISTS sprint_status") op.execute("DROP TYPE IF EXISTS sprint_status")
op.execute("DROP TYPE IF EXISTS sync_status") op.execute("DROP TYPE IF EXISTS sync_status")
op.execute("DROP TYPE IF EXISTS external_tracker_type")
op.execute("DROP TYPE IF EXISTS issue_priority") op.execute("DROP TYPE IF EXISTS issue_priority")
op.execute("DROP TYPE IF EXISTS issue_status") op.execute("DROP TYPE IF EXISTS issue_status")
op.execute("DROP TYPE IF EXISTS issue_type")
op.execute("DROP TYPE IF EXISTS agent_status") op.execute("DROP TYPE IF EXISTS agent_status")
op.execute("DROP TYPE IF EXISTS client_mode") op.execute("DROP TYPE IF EXISTS client_mode")
op.execute("DROP TYPE IF EXISTS project_complexity") op.execute("DROP TYPE IF EXISTS project_complexity")

View File

@@ -5,7 +5,7 @@ Sprint model for Syndarix AI consulting platform.
A Sprint represents a time-boxed iteration for organizing and delivering work. A Sprint represents a time-boxed iteration for organizing and delivering work.
""" """
from sqlalchemy import Column, Date, Enum, ForeignKey, Index, Integer, String, Text from sqlalchemy import Column, Date, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -65,6 +65,8 @@ class Sprint(Base, UUIDMixin, TimestampMixin):
Index("ix_sprints_project_status", "project_id", "status"), Index("ix_sprints_project_status", "project_id", "status"),
Index("ix_sprints_project_number", "project_id", "number"), Index("ix_sprints_project_number", "project_id", "number"),
Index("ix_sprints_date_range", "start_date", "end_date"), Index("ix_sprints_date_range", "start_date", "end_date"),
# Ensure sprint numbers are unique within a project
UniqueConstraint("project_id", "number", name="uq_sprint_project_number"),
) )
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@@ -0,0 +1,158 @@
/**
* Error Boundary Component
*
* Catches JavaScript errors in child component tree and displays fallback UI.
* Prevents the entire app from crashing due to component-level errors.
*
* @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
'use client';
import { Component, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
// ============================================================================
// Types
// ============================================================================
interface ErrorBoundaryProps {
/** Child components to render */
children: ReactNode;
/** Optional fallback component to render on error */
fallback?: ReactNode;
/** Optional callback when error occurs */
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
/** Whether to show reset button (default: true) */
showReset?: boolean;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
// ============================================================================
// Default Fallback Component
// ============================================================================
interface DefaultFallbackProps {
error: Error | null;
onReset: () => void;
showReset: boolean;
}
function DefaultFallback({ error, onReset, showReset }: DefaultFallbackProps) {
return (
<Card className="m-4 border-destructive/50 bg-destructive/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
Something went wrong
</CardTitle>
<CardDescription>
An unexpected error occurred. Please try again or contact support if
the problem persists.
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 rounded-md bg-muted p-3">
<p className="font-mono text-sm text-muted-foreground">
{error.message}
</p>
</div>
)}
{showReset && (
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Try again
</Button>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Error Boundary Component
// ============================================================================
/**
* Error boundary for catching and handling render errors in React components.
*
* @example
* ```tsx
* <ErrorBoundary onError={(error) => logError(error)}>
* <MyComponent />
* </ErrorBoundary>
* ```
*
* @example With custom fallback
* ```tsx
* <ErrorBoundary fallback={<CustomErrorUI />}>
* <MyComponent />
* </ErrorBoundary>
* ```
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// Log error to console in development
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error callback
this.props.onError?.(error, errorInfo);
}
handleReset = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
const { hasError, error } = this.state;
const { children, fallback, showReset = true } = this.props;
if (hasError) {
if (fallback) {
return fallback;
}
return (
<DefaultFallback
error={error}
onReset={this.handleReset}
showReset={showReset}
/>
);
}
return children;
}
}
export default ErrorBoundary;

View File

@@ -1,4 +1,4 @@
// Common reusable components // Common reusable components
// Examples: LoadingSpinner, ErrorBoundary, ConfirmDialog, etc. // Examples: LoadingSpinner, ErrorBoundary, ConfirmDialog, etc.
export {}; export { ErrorBoundary } from './ErrorBoundary';

View File

@@ -126,7 +126,7 @@ function AgentListItem({
<DropdownMenuItem onClick={() => onAction(agent.id, 'view')}> <DropdownMenuItem onClick={() => onAction(agent.id, 'view')}>
View Details View Details
</DropdownMenuItem> </DropdownMenuItem>
{agent.status === 'active' || agent.status === 'working' ? ( {agent.status === 'working' || agent.status === 'waiting' ? (
<DropdownMenuItem onClick={() => onAction(agent.id, 'pause')}> <DropdownMenuItem onClick={() => onAction(agent.id, 'pause')}>
Pause Agent Pause Agent
</DropdownMenuItem> </DropdownMenuItem>
@@ -196,7 +196,7 @@ export function AgentPanel({
} }
const activeAgentCount = agents.filter( const activeAgentCount = agents.filter(
(a) => a.status === 'active' || a.status === 'working' (a) => a.status === 'working' || a.status === 'waiting'
).length; ).length;
return ( return (

View File

@@ -14,21 +14,17 @@ const statusConfig: Record<AgentStatus, { color: string; label: string }> = {
color: 'bg-yellow-500', color: 'bg-yellow-500',
label: 'Idle', label: 'Idle',
}, },
active: {
color: 'bg-green-500',
label: 'Active',
},
working: { working: {
color: 'bg-green-500 animate-pulse', color: 'bg-green-500 animate-pulse',
label: 'Working', label: 'Working',
}, },
pending: { waiting: {
color: 'bg-gray-400', color: 'bg-blue-500',
label: 'Pending', label: 'Waiting',
}, },
error: { paused: {
color: 'bg-red-500', color: 'bg-gray-400',
label: 'Error', label: 'Paused',
}, },
terminated: { terminated: {
color: 'bg-gray-600', color: 'bg-gray-600',
@@ -49,7 +45,7 @@ export function AgentStatusIndicator({
showLabel = false, showLabel = false,
className, className,
}: AgentStatusIndicatorProps) { }: AgentStatusIndicatorProps) {
const config = statusConfig[status] || statusConfig.pending; const config = statusConfig[status] || statusConfig.idle;
const sizeClasses = { const sizeClasses = {
sm: 'h-2 w-2', sm: 'h-2 w-2',

View File

@@ -24,7 +24,7 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import type { IssueSummary as IssueSummaryType } from './types'; import type { IssueCountSummary } from './types';
// ============================================================================ // ============================================================================
// Types // Types
@@ -32,7 +32,7 @@ import type { IssueSummary as IssueSummaryType } from './types';
interface IssueSummaryProps { interface IssueSummaryProps {
/** Issue summary data */ /** Issue summary data */
summary: IssueSummaryType | null; summary: IssueCountSummary | null;
/** Whether data is loading */ /** Whether data is loading */
isLoading?: boolean; isLoading?: boolean;
/** Callback when "View All Issues" is clicked */ /** Callback when "View All Issues" is clicked */
@@ -171,8 +171,8 @@ export function IssueSummary({
<StatusRow <StatusRow
icon={CheckCircle2} icon={CheckCircle2}
iconColor="text-green-500" iconColor="text-green-500"
label="Completed" label="Closed"
count={summary.done} count={summary.closed}
/> />
{onViewAllIssues && ( {onViewAllIssues && (

View File

@@ -28,7 +28,7 @@ import type {
AgentInstance, AgentInstance,
Sprint, Sprint,
BurndownDataPoint, BurndownDataPoint,
IssueSummary as IssueSummaryType, IssueCountSummary,
ActivityItem, ActivityItem,
} from './types'; } from './types';
@@ -52,7 +52,7 @@ const mockProject: Project = {
id: 'proj-001', id: 'proj-001',
name: 'E-Commerce Platform Redesign', name: 'E-Commerce Platform Redesign',
description: 'Complete redesign of the e-commerce platform with modern UI/UX', description: 'Complete redesign of the e-commerce platform with modern UI/UX',
status: 'in_progress', status: 'active',
autonomy_level: 'milestone', autonomy_level: 'milestone',
current_sprint_id: 'sprint-003', current_sprint_id: 'sprint-003',
created_at: '2025-01-15T00:00:00Z', created_at: '2025-01-15T00:00:00Z',
@@ -66,7 +66,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001', project_id: 'proj-001',
name: 'Product Owner', name: 'Product Owner',
role: 'product_owner', role: 'product_owner',
status: 'active', status: 'working',
current_task: 'Reviewing user story acceptance criteria', current_task: 'Reviewing user story acceptance criteria',
last_activity_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(), last_activity_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
spawned_at: '2025-01-15T00:00:00Z', spawned_at: '2025-01-15T00:00:00Z',
@@ -102,7 +102,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001', project_id: 'proj-001',
name: 'Frontend Engineer', name: 'Frontend Engineer',
role: 'frontend_engineer', role: 'frontend_engineer',
status: 'active', status: 'working',
current_task: 'Implementing product catalog component', current_task: 'Implementing product catalog component',
last_activity_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(), last_activity_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
spawned_at: '2025-01-15T00:00:00Z', spawned_at: '2025-01-15T00:00:00Z',
@@ -114,7 +114,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001', project_id: 'proj-001',
name: 'QA Engineer', name: 'QA Engineer',
role: 'qa_engineer', role: 'qa_engineer',
status: 'pending', status: 'waiting',
current_task: 'Preparing test cases for Sprint 3', current_task: 'Preparing test cases for Sprint 3',
last_activity_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), last_activity_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
spawned_at: '2025-01-15T00:00:00Z', spawned_at: '2025-01-15T00:00:00Z',
@@ -148,12 +148,12 @@ const mockBurndownData: BurndownDataPoint[] = [
{ day: 8, remaining: 20, ideal: 24 }, { day: 8, remaining: 20, ideal: 24 },
]; ];
const mockIssueSummary: IssueSummaryType = { const mockIssueSummary: IssueCountSummary = {
open: 12, open: 12,
in_progress: 8, in_progress: 8,
in_review: 3, in_review: 3,
blocked: 2, blocked: 2,
done: 45, closed: 45,
total: 70, total: 70,
}; };
@@ -392,7 +392,7 @@ export function ProjectDashboard({ projectId, className }: ProjectDashboardProps
<ProjectHeader <ProjectHeader
project={project} project={project}
isLoading={isLoading} isLoading={isLoading}
canPause={project.status === 'in_progress'} canPause={project.status === 'active'}
canStart={true} canStart={true}
onStartSprint={handleStartSprint} onStartSprint={handleStartSprint}
onPauseProject={handlePauseProject} onPauseProject={handlePauseProject}

View File

@@ -84,7 +84,7 @@ export function ProjectHeader({
return null; return null;
} }
const showPauseButton = canPause && project.status === 'in_progress'; const showPauseButton = canPause && project.status === 'active';
const showStartButton = canStart && project.status !== 'completed' && project.status !== 'archived'; const showStartButton = canStart && project.status !== 'completed' && project.status !== 'archived';
return ( return (

View File

@@ -16,12 +16,8 @@ import type { ProjectStatus, AutonomyLevel } from './types';
// ============================================================================ // ============================================================================
const projectStatusConfig: Record<ProjectStatus, { label: string; className: string }> = { const projectStatusConfig: Record<ProjectStatus, { label: string; className: string }> = {
draft: { active: {
label: 'Draft', label: 'Active',
className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
},
in_progress: {
label: 'In Progress',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
}, },
paused: { paused: {
@@ -32,10 +28,6 @@ const projectStatusConfig: Record<ProjectStatus, { label: string; className: str
label: 'Completed', label: 'Completed',
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
}, },
blocked: {
label: 'Blocked',
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
},
archived: { archived: {
label: 'Archived', label: 'Archived',
className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
@@ -48,7 +40,7 @@ interface ProjectStatusBadgeProps {
} }
export function ProjectStatusBadge({ status, className }: ProjectStatusBadgeProps) { export function ProjectStatusBadge({ status, className }: ProjectStatusBadgeProps) {
const config = projectStatusConfig[status] || projectStatusConfig.draft; const config = projectStatusConfig[status] || projectStatusConfig.active;
return ( return (
<Badge variant="outline" className={cn(config.className, className)}> <Badge variant="outline" className={cn(config.className, className)}>

View File

@@ -11,7 +11,10 @@
// Project Types // Project Types
// ============================================================================ // ============================================================================
export type ProjectStatus = 'draft' | 'in_progress' | 'paused' | 'completed' | 'blocked' | 'archived'; /**
* Matches backend: ProjectStatus enum in app/models/syndarix/enums.py
*/
export type ProjectStatus = 'active' | 'paused' | 'completed' | 'archived';
export type AutonomyLevel = 'full_control' | 'milestone' | 'autonomous'; export type AutonomyLevel = 'full_control' | 'milestone' | 'autonomous';
@@ -31,7 +34,10 @@ export interface Project {
// Agent Types // Agent Types
// ============================================================================ // ============================================================================
export type AgentStatus = 'idle' | 'active' | 'working' | 'pending' | 'error' | 'terminated'; /**
* Matches backend: AgentStatus enum in app/models/syndarix/enums.py
*/
export type AgentStatus = 'idle' | 'working' | 'waiting' | 'paused' | 'terminated';
export interface AgentInstance { export interface AgentInstance {
id: string; id: string;
@@ -50,7 +56,10 @@ export interface AgentInstance {
// Sprint Types // Sprint Types
// ============================================================================ // ============================================================================
export type SprintStatus = 'planning' | 'active' | 'review' | 'completed'; /**
* Matches backend: SprintStatus enum in app/models/syndarix/enums.py
*/
export type SprintStatus = 'planned' | 'active' | 'in_review' | 'completed' | 'cancelled';
export interface Sprint { export interface Sprint {
id: string; id: string;
@@ -78,7 +87,10 @@ export interface BurndownDataPoint {
// Issue Types // Issue Types
// ============================================================================ // ============================================================================
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'done' | 'closed'; /**
* Matches backend: IssueStatus enum in app/models/syndarix/enums.py
*/
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'closed';
export type IssuePriority = 'low' | 'medium' | 'high' | 'critical'; export type IssuePriority = 'low' | 'medium' | 'high' | 'critical';
@@ -96,12 +108,12 @@ export interface Issue {
labels?: string[]; labels?: string[];
} }
export interface IssueSummary { export interface IssueCountSummary {
open: number; open: number;
in_progress: number; in_progress: number;
in_review: number; in_review: number;
blocked: number; blocked: number;
done: number; closed: number;
total: number; total: number;
} }

View File

@@ -26,7 +26,6 @@ const STATUS_ICONS = {
in_progress: PlayCircle, in_progress: PlayCircle,
in_review: Clock, in_review: Clock,
blocked: AlertCircle, blocked: AlertCircle,
done: CheckCircle2,
closed: XCircle, closed: XCircle,
} as const; } as const;

View File

@@ -22,14 +22,17 @@ export const STATUS_CONFIG: Record<IssueStatus, StatusConfig> = {
in_progress: { label: 'In Progress', color: 'text-yellow-500' }, in_progress: { label: 'In Progress', color: 'text-yellow-500' },
in_review: { label: 'In Review', color: 'text-purple-500' }, in_review: { label: 'In Review', color: 'text-purple-500' },
blocked: { label: 'Blocked', color: 'text-red-500' }, blocked: { label: 'Blocked', color: 'text-red-500' },
done: { label: 'Done', color: 'text-green-500' }, closed: { label: 'Closed', color: 'text-green-500' },
closed: { label: 'Closed', color: 'text-muted-foreground' },
}; };
/** /**
* Priority configuration with labels and colors * Priority configuration with labels and colors
*/ */
export const PRIORITY_CONFIG: Record<IssuePriority, PriorityConfig> = { export const PRIORITY_CONFIG: Record<IssuePriority, PriorityConfig> = {
critical: {
label: 'Critical',
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
},
high: { label: 'High', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }, high: { label: 'High', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
medium: { medium: {
label: 'Medium', label: 'Medium',
@@ -46,10 +49,9 @@ export const STATUS_TRANSITIONS: StatusTransition[] = [
{ from: 'open', to: 'in_progress', label: 'Start Work' }, { from: 'open', to: 'in_progress', label: 'Start Work' },
{ from: 'in_progress', to: 'in_review', label: 'Submit for Review' }, { from: 'in_progress', to: 'in_review', label: 'Submit for Review' },
{ from: 'in_progress', to: 'blocked', label: 'Mark Blocked' }, { from: 'in_progress', to: 'blocked', label: 'Mark Blocked' },
{ from: 'in_review', to: 'done', label: 'Mark Done' }, { from: 'in_review', to: 'closed', label: 'Complete' },
{ from: 'in_review', to: 'in_progress', label: 'Request Changes' }, { from: 'in_review', to: 'in_progress', label: 'Request Changes' },
{ from: 'blocked', to: 'in_progress', label: 'Unblock' }, { from: 'blocked', to: 'in_progress', label: 'Unblock' },
{ from: 'done', to: 'closed', label: 'Close Issue' },
{ from: 'closed', to: 'open', label: 'Reopen' }, { from: 'closed', to: 'open', label: 'Reopen' },
]; ];
@@ -76,14 +78,13 @@ export const STATUS_ORDER: IssueStatus[] = [
'in_progress', 'in_progress',
'in_review', 'in_review',
'blocked', 'blocked',
'done',
'closed', 'closed',
]; ];
/** /**
* All possible priorities in order * All possible priorities in order
*/ */
export const PRIORITY_ORDER: IssuePriority[] = ['high', 'medium', 'low']; export const PRIORITY_ORDER: IssuePriority[] = ['critical', 'high', 'medium', 'low'];
/** /**
* Sync status configuration * Sync status configuration

View File

@@ -94,7 +94,7 @@ function filterAndSortIssues(
case 'number': case 'number':
return (a.number - b.number) * direction; return (a.number - b.number) * direction;
case 'priority': { case 'priority': {
const priorityOrder = { high: 3, medium: 2, low: 1 }; const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
return (priorityOrder[a.priority] - priorityOrder[b.priority]) * direction; return (priorityOrder[a.priority] - priorityOrder[b.priority]) * direction;
} }
case 'updated_at': case 'updated_at':

View File

@@ -16,6 +16,7 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-001', id: 'ISS-001',
number: 42, number: 42,
type: 'story',
title: 'Implement user authentication flow', title: 'Implement user authentication flow',
description: description:
'Create complete authentication flow with login, register, and password reset.', 'Create complete authentication flow with login, register, and password reset.',
@@ -31,6 +32,7 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-002', id: 'ISS-002',
number: 43, number: 43,
type: 'task',
title: 'Design product catalog component', title: 'Design product catalog component',
description: 'Create reusable product card and catalog grid components.', description: 'Create reusable product card and catalog grid components.',
status: 'in_review', status: 'in_review',
@@ -45,6 +47,7 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-003', id: 'ISS-003',
number: 44, number: 44,
type: 'bug',
title: 'Fix cart total calculation bug', title: 'Fix cart total calculation bug',
description: 'Cart total shows incorrect amount when discount is applied.', description: 'Cart total shows incorrect amount when discount is applied.',
status: 'blocked', status: 'blocked',
@@ -60,6 +63,7 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-004', id: 'ISS-004',
number: 45, number: 45,
type: 'story',
title: 'Add product search functionality', title: 'Add product search functionality',
description: 'Implement full-text search with filters for the product catalog.', description: 'Implement full-text search with filters for the product catalog.',
status: 'open', status: 'open',
@@ -74,9 +78,10 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-005', id: 'ISS-005',
number: 46, number: 46,
type: 'task',
title: 'Optimize database queries for product listing', title: 'Optimize database queries for product listing',
description: 'Performance optimization for product queries with pagination.', description: 'Performance optimization for product queries with pagination.',
status: 'done', status: 'closed',
priority: 'low', priority: 'low',
labels: ['performance', 'backend', 'database'], labels: ['performance', 'backend', 'database'],
sprint: 'Sprint 2', sprint: 'Sprint 2',
@@ -88,9 +93,10 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-006', id: 'ISS-006',
number: 47, number: 47,
type: 'task',
title: 'Create checkout page wireframes', title: 'Create checkout page wireframes',
description: 'Design wireframes for the checkout flow including payment selection.', description: 'Design wireframes for the checkout flow including payment selection.',
status: 'done', status: 'closed',
priority: 'high', priority: 'high',
labels: ['design', 'checkout', 'ui'], labels: ['design', 'checkout', 'ui'],
sprint: 'Sprint 2', sprint: 'Sprint 2',
@@ -102,6 +108,7 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-007', id: 'ISS-007',
number: 48, number: 48,
type: 'story',
title: 'Implement responsive navigation', title: 'Implement responsive navigation',
description: 'Create mobile-friendly navigation with hamburger menu.', description: 'Create mobile-friendly navigation with hamburger menu.',
status: 'open', status: 'open',
@@ -116,6 +123,7 @@ export const mockIssues: IssueSummary[] = [
{ {
id: 'ISS-008', id: 'ISS-008',
number: 49, number: 49,
type: 'task',
title: 'Set up E2E test framework', title: 'Set up E2E test framework',
description: 'Configure Playwright for end-to-end testing.', description: 'Configure Playwright for end-to-end testing.',
status: 'in_progress', status: 'in_progress',
@@ -135,6 +143,7 @@ export const mockIssues: IssueSummary[] = [
export const mockIssueDetail: IssueDetail = { export const mockIssueDetail: IssueDetail = {
id: 'ISS-001', id: 'ISS-001',
number: 42, number: 42,
type: 'story',
title: 'Implement user authentication flow', title: 'Implement user authentication flow',
description: `## Overview description: `## Overview
Create a complete authentication flow for the e-commerce platform. Create a complete authentication flow for the e-commerce platform.

View File

@@ -8,14 +8,22 @@
*/ */
/** /**
* Issue status values * Issue type values (for hierarchy: Epic -> Story -> Task)
* Matches backend: IssueType enum in app/models/syndarix/enums.py
*/ */
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'done' | 'closed'; export type IssueType = 'epic' | 'story' | 'task' | 'bug';
/**
* Issue status values
* Matches backend: IssueStatus enum in app/models/syndarix/enums.py
*/
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'closed';
/** /**
* Issue priority values * Issue priority values
* Matches backend: IssuePriority enum in app/models/syndarix/enums.py
*/ */
export type IssuePriority = 'high' | 'medium' | 'low'; export type IssuePriority = 'low' | 'medium' | 'high' | 'critical';
/** /**
* Sync status with external trackers * Sync status with external trackers
@@ -64,6 +72,7 @@ export interface IssueActivity {
export interface IssueSummary { export interface IssueSummary {
id: string; id: string;
number: number; number: number;
type: IssueType;
title: string; title: string;
description: string; description: string;
status: IssueStatus; status: IssueStatus;

View File

@@ -10,7 +10,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001', project_id: 'proj-001',
name: 'Product Owner', name: 'Product Owner',
role: 'product_owner', role: 'product_owner',
status: 'active', status: 'working',
current_task: 'Reviewing user stories', current_task: 'Reviewing user stories',
last_activity_at: new Date().toISOString(), last_activity_at: new Date().toISOString(),
spawned_at: '2025-01-15T00:00:00Z', spawned_at: '2025-01-15T00:00:00Z',

View File

@@ -8,12 +8,6 @@ describe('AgentStatusIndicator', () => {
expect(indicator).toHaveClass('bg-yellow-500'); expect(indicator).toHaveClass('bg-yellow-500');
}); });
it('renders active status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="active" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('bg-green-500');
});
it('renders working status with animation', () => { it('renders working status with animation', () => {
const { container } = render(<AgentStatusIndicator status="working" />); const { container } = render(<AgentStatusIndicator status="working" />);
const indicator = container.querySelector('span > span'); const indicator = container.querySelector('span > span');
@@ -21,16 +15,16 @@ describe('AgentStatusIndicator', () => {
expect(indicator).toHaveClass('animate-pulse'); expect(indicator).toHaveClass('animate-pulse');
}); });
it('renders pending status with correct color', () => { it('renders waiting status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="pending" />); const { container } = render(<AgentStatusIndicator status="waiting" />);
const indicator = container.querySelector('span > span'); const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('bg-gray-400'); expect(indicator).toHaveClass('bg-blue-500');
}); });
it('renders error status with correct color', () => { it('renders paused status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="error" />); const { container } = render(<AgentStatusIndicator status="paused" />);
const indicator = container.querySelector('span > span'); const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('bg-red-500'); expect(indicator).toHaveClass('bg-gray-400');
}); });
it('renders terminated status with correct color', () => { it('renders terminated status with correct color', () => {
@@ -40,41 +34,41 @@ describe('AgentStatusIndicator', () => {
}); });
it('applies small size by default', () => { it('applies small size by default', () => {
const { container } = render(<AgentStatusIndicator status="active" />); const { container } = render(<AgentStatusIndicator status="working" />);
const indicator = container.querySelector('span > span'); const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('h-2', 'w-2'); expect(indicator).toHaveClass('h-2', 'w-2');
}); });
it('applies medium size when specified', () => { it('applies medium size when specified', () => {
const { container } = render(<AgentStatusIndicator status="active" size="md" />); const { container } = render(<AgentStatusIndicator status="working" size="md" />);
const indicator = container.querySelector('span > span'); const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('h-3', 'w-3'); expect(indicator).toHaveClass('h-3', 'w-3');
}); });
it('applies large size when specified', () => { it('applies large size when specified', () => {
const { container } = render(<AgentStatusIndicator status="active" size="lg" />); const { container } = render(<AgentStatusIndicator status="working" size="lg" />);
const indicator = container.querySelector('span > span'); const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('h-4', 'w-4'); expect(indicator).toHaveClass('h-4', 'w-4');
}); });
it('shows label when showLabel is true', () => { it('shows label when showLabel is true', () => {
render(<AgentStatusIndicator status="active" showLabel />); render(<AgentStatusIndicator status="working" showLabel />);
expect(screen.getByText('Active')).toBeInTheDocument(); expect(screen.getByText('Working')).toBeInTheDocument();
}); });
it('does not show label by default', () => { it('does not show label by default', () => {
render(<AgentStatusIndicator status="active" />); render(<AgentStatusIndicator status="working" />);
expect(screen.queryByText('Active')).not.toBeInTheDocument(); expect(screen.queryByText('Working')).not.toBeInTheDocument();
}); });
it('has accessible status role and label', () => { it('has accessible status role and label', () => {
render(<AgentStatusIndicator status="active" />); render(<AgentStatusIndicator status="working" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Status: Active'); expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Status: Working');
}); });
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render( const { container } = render(
<AgentStatusIndicator status="active" className="custom-class" /> <AgentStatusIndicator status="working" className="custom-class" />
); );
expect(container.firstChild).toHaveClass('custom-class'); expect(container.firstChild).toHaveClass('custom-class');
}); });

View File

@@ -1,14 +1,14 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IssueSummary } from '@/components/projects/IssueSummary'; import { IssueSummary } from '@/components/projects/IssueSummary';
import type { IssueSummary as IssueSummaryType } from '@/components/projects/types'; import type { IssueCountSummary } from '@/components/projects/types';
const mockSummary: IssueSummaryType = { const mockSummary: IssueCountSummary = {
open: 12, open: 12,
in_progress: 8, in_progress: 8,
in_review: 3, in_review: 3,
blocked: 2, blocked: 2,
done: 45, closed: 45,
total: 70, total: 70,
}; };
@@ -33,7 +33,7 @@ describe('IssueSummary', () => {
expect(screen.getByText('Blocked')).toBeInTheDocument(); expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('Completed')).toBeInTheDocument(); expect(screen.getByText('Closed')).toBeInTheDocument();
expect(screen.getByText('45')).toBeInTheDocument(); expect(screen.getByText('45')).toBeInTheDocument();
}); });

View File

@@ -7,7 +7,7 @@ const mockProject: Project = {
id: 'proj-001', id: 'proj-001',
name: 'Test Project', name: 'Test Project',
description: 'A test project for unit testing', description: 'A test project for unit testing',
status: 'in_progress', status: 'active',
autonomy_level: 'milestone', autonomy_level: 'milestone',
created_at: '2025-01-15T00:00:00Z', created_at: '2025-01-15T00:00:00Z',
owner_id: 'user-001', owner_id: 'user-001',
@@ -26,7 +26,7 @@ describe('ProjectHeader', () => {
it('renders project status badge', () => { it('renders project status badge', () => {
render(<ProjectHeader project={mockProject} />); render(<ProjectHeader project={mockProject} />);
expect(screen.getByText('In Progress')).toBeInTheDocument(); expect(screen.getByText('Active')).toBeInTheDocument();
}); });
it('renders autonomy level badge', () => { it('renders autonomy level badge', () => {
@@ -44,7 +44,7 @@ describe('ProjectHeader', () => {
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
}); });
it('shows pause button when canPause is true and project is in_progress', () => { it('shows pause button when canPause is true and project is active', () => {
const onPauseProject = jest.fn(); const onPauseProject = jest.fn();
render( render(
<ProjectHeader <ProjectHeader
@@ -56,7 +56,7 @@ describe('ProjectHeader', () => {
expect(screen.getByRole('button', { name: /pause project/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /pause project/i })).toBeInTheDocument();
}); });
it('does not show pause button when project is not in_progress', () => { it('does not show pause button when project is not active', () => {
const completedProject = { ...mockProject, status: 'completed' as const }; const completedProject = { ...mockProject, status: 'completed' as const };
render(<ProjectHeader project={completedProject} canPause={true} />); render(<ProjectHeader project={completedProject} canPause={true} />);
expect(screen.queryByRole('button', { name: /pause project/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /pause project/i })).not.toBeInTheDocument();

View File

@@ -2,9 +2,9 @@ import { render, screen } from '@testing-library/react';
import { ProjectStatusBadge, AutonomyBadge } from '@/components/projects/StatusBadge'; import { ProjectStatusBadge, AutonomyBadge } from '@/components/projects/StatusBadge';
describe('ProjectStatusBadge', () => { describe('ProjectStatusBadge', () => {
it('renders in_progress status correctly', () => { it('renders active status correctly', () => {
render(<ProjectStatusBadge status="in_progress" />); render(<ProjectStatusBadge status="active" />);
expect(screen.getByText('In Progress')).toBeInTheDocument(); expect(screen.getByText('Active')).toBeInTheDocument();
}); });
it('renders completed status correctly', () => { it('renders completed status correctly', () => {
@@ -12,21 +12,11 @@ describe('ProjectStatusBadge', () => {
expect(screen.getByText('Completed')).toBeInTheDocument(); expect(screen.getByText('Completed')).toBeInTheDocument();
}); });
it('renders blocked status correctly', () => {
render(<ProjectStatusBadge status="blocked" />);
expect(screen.getByText('Blocked')).toBeInTheDocument();
});
it('renders paused status correctly', () => { it('renders paused status correctly', () => {
render(<ProjectStatusBadge status="paused" />); render(<ProjectStatusBadge status="paused" />);
expect(screen.getByText('Paused')).toBeInTheDocument(); expect(screen.getByText('Paused')).toBeInTheDocument();
}); });
it('renders draft status correctly', () => {
render(<ProjectStatusBadge status="draft" />);
expect(screen.getByText('Draft')).toBeInTheDocument();
});
it('renders archived status correctly', () => { it('renders archived status correctly', () => {
render(<ProjectStatusBadge status="archived" />); render(<ProjectStatusBadge status="archived" />);
expect(screen.getByText('Archived')).toBeInTheDocument(); expect(screen.getByText('Archived')).toBeInTheDocument();
@@ -34,7 +24,7 @@ describe('ProjectStatusBadge', () => {
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render( const { container } = render(
<ProjectStatusBadge status="in_progress" className="custom-class" /> <ProjectStatusBadge status="active" className="custom-class" />
); );
expect(container.firstChild).toHaveClass('custom-class'); expect(container.firstChild).toHaveClass('custom-class');
}); });

View File

@@ -11,6 +11,7 @@ const mockIssues: IssueSummary[] = [
{ {
id: 'issue-1', id: 'issue-1',
number: 42, number: 42,
type: 'bug',
title: 'Test Issue 1', title: 'Test Issue 1',
description: 'Description 1', description: 'Description 1',
status: 'open', status: 'open',
@@ -25,6 +26,7 @@ const mockIssues: IssueSummary[] = [
{ {
id: 'issue-2', id: 'issue-2',
number: 43, number: 43,
type: 'story',
title: 'Test Issue 2', title: 'Test Issue 2',
description: 'Description 2', description: 'Description 2',
status: 'in_progress', status: 'in_progress',

View File

@@ -11,12 +11,11 @@ const statusLabels: Record<IssueStatus, string> = {
in_progress: 'In Progress', in_progress: 'In Progress',
in_review: 'In Review', in_review: 'In Review',
blocked: 'Blocked', blocked: 'Blocked',
done: 'Done',
closed: 'Closed', closed: 'Closed',
}; };
describe('StatusBadge', () => { describe('StatusBadge', () => {
const statuses: IssueStatus[] = ['open', 'in_progress', 'in_review', 'blocked', 'done', 'closed']; const statuses: IssueStatus[] = ['open', 'in_progress', 'in_review', 'blocked', 'closed'];
it.each(statuses)('renders %s status correctly', (status) => { it.each(statuses)('renders %s status correctly', (status) => {
render(<StatusBadge status={status} />); render(<StatusBadge status={status} />);

View File

@@ -22,7 +22,6 @@ describe('StatusWorkflow', () => {
expect(screen.getByText('In Progress')).toBeInTheDocument(); expect(screen.getByText('In Progress')).toBeInTheDocument();
expect(screen.getByText('In Review')).toBeInTheDocument(); expect(screen.getByText('In Review')).toBeInTheDocument();
expect(screen.getByText('Blocked')).toBeInTheDocument(); expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('Closed')).toBeInTheDocument(); expect(screen.getByText('Closed')).toBeInTheDocument();
}); });