[W9-3c] Enrich project table with task counts + CI-watch badge (#531)

Backend: ProjectSummaryResponse gains task_counts (done/active/blocked) + ci_watch_enabled. ProjectService.task_counts_for_projects does one GROUP BY project_id over TaskTable for every distinct project_id in the list (a project with no tasks is absent — route falls back to None). ci_watch_enabled is read straight off the Project row (already a column) — a 0-cost schema extension, honest signal that CI-watch is armed, no live-conclusion fan-out. project_to_summary takes an optional task_counts. No migration.

Frontend: ProjectTable gains a Tasks column (done/active/blocked + health dot, amber at-risk when blocked>0) and a CI-Watch badge under the project name when ci_watch_enabled. Both desktop Table and mobile ResponsiveTableCard variants. Mock projects carry the new shape (two sample repos).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:34:06 +02:00
committed by GitHub
co-authored by Renn F
parent 86d31bf3d3
commit 1054538d2f
9 changed files with 379 additions and 21 deletions
@@ -47,6 +47,8 @@ function project(overrides: Partial<ProjectSummary>): ProjectSummary {
has_workspace: true,
has_git_token: true,
video_engine_enabled: false,
ci_watch_enabled: false,
task_counts: null,
...overrides,
};
}
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProjectTable } from "../project-table";
import { Team } from "@/types";
import type { ProjectSummary } from "@/types";
const project: ProjectSummary = {
id: "p1",
name: "RoboCo Core",
slug: "roboco",
git_url: "https://github.com/rennf93/roboco.git",
assigned_cell: Team.BACKEND,
is_active: true,
has_workspace: true,
has_git_token: true,
video_engine_enabled: false,
ci_watch_enabled: true,
task_counts: { done: 42, active: 5, blocked: 1 },
};
describe("ProjectTable", () => {
it("renders the project name, task counts, and CI-watch badge", () => {
render(<ProjectTable projects={[project]} isLoading={false} />);
expect(screen.getByText("RoboCo Core")).toBeInTheDocument();
expect(screen.getByText("42 done")).toBeInTheDocument();
expect(screen.getByText("1 blocked")).toBeInTheDocument();
expect(screen.getByText("CI-Watch")).toBeInTheDocument();
});
it("shows the empty state when there are no projects", () => {
render(<ProjectTable projects={[]} isLoading={false} />);
expect(screen.getByText("No projects found")).toBeInTheDocument();
});
it("renders an em-dash placeholder when task_counts is null", () => {
const bare: ProjectSummary = {
...project,
id: "p2",
name: "bare-project",
ci_watch_enabled: false,
task_counts: null,
};
render(<ProjectTable projects={[bare]} isLoading={false} />);
// Renders the row (not the empty state) and the CI-Watch badge is absent.
expect(screen.getByText("bare-project")).toBeInTheDocument();
expect(screen.queryByText("CI-Watch")).not.toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<ProjectTable projects={undefined} isLoading={true} />);
expect(screen.queryByText("No projects found")).not.toBeInTheDocument();
});
});
+76 -14
View File
@@ -18,8 +18,8 @@ import {
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink, Pencil, GitBranch, Key, KeyRound } from "lucide-react";
import type { ProjectSummary, Team } from "@/types";
import { ExternalLink, Pencil, GitBranch, Key, KeyRound, Radar } from "lucide-react";
import type { ProjectSummary, ProjectTaskCounts, Team } from "@/types";
import { EditProjectDialog } from "./edit-project-dialog";
interface ProjectTableProps {
@@ -62,6 +62,53 @@ function getTokenBadge(hasGitToken: boolean) {
);
}
function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
if (!counts) {
return <span className="text-muted-foreground text-xs"></span>;
}
const atRisk = counts.blocked > 0;
return (
<div className="flex items-center gap-2">
<span
className={
"h-2 w-2 rounded-full " +
(atRisk
? "bg-amber-500"
: counts.done > 0
? "bg-emerald-500"
: "bg-muted")
}
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
/>
<div className="flex items-center gap-2 text-xs">
<span className="text-emerald-600 dark:text-emerald-400">
{counts.done} done
</span>
<span className="text-muted-foreground">{counts.active} active</span>
{counts.blocked > 0 && (
<span className="text-amber-600 dark:text-amber-400">
{counts.blocked} blocked
</span>
)}
</div>
</div>
);
}
function CiWatchBadge({ enabled }: { enabled: boolean }) {
if (!enabled) return null;
return (
<Badge
variant="outline"
className="bg-sky-500/10 text-sky-500 border-sky-500/30"
title="CI-watch armed"
>
<Radar className="h-3 w-3 mr-1" />
CI-Watch
</Badge>
);
}
export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
@@ -111,6 +158,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Cell</TableHead>
<TableHead>Tasks</TableHead>
<TableHead>Token</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
@@ -131,6 +179,11 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<p className="text-xs text-muted-foreground font-mono">
{project.slug}
</p>
{project.ci_watch_enabled && (
<div className="mt-1">
<CiWatchBadge enabled />
</div>
)}
</div>
</TableCell>
<TableCell>
@@ -138,6 +191,9 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
{teamLabels[project.assigned_cell]}
</Badge>
</TableCell>
<TableCell>
<TasksCell counts={project.task_counts} />
</TableCell>
<TableCell>
{getTokenBadge(project.has_git_token)}
</TableCell>
@@ -235,22 +291,28 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
{teamLabels[project.assigned_cell]}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Tasks">
<TasksCell counts={project.task_counts} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Token">
{getTokenBadge(project.has_git_token)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
<div className="flex items-center gap-2">
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
{project.ci_watch_enabled && <CiWatchBadge enabled />}
</div>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
+48 -2
View File
@@ -1,15 +1,59 @@
import api from "./client";
import { Team } from "@/types";
import type {
Project,
ProjectCreate,
ProjectUpdate,
ProjectSummary,
Team,
} from "@/types";
import { isMockMode } from "@/lib/mock-data";
// Mock data for offline mode
const mockProjects: Project[] = [];
const mockProjects: Project[] = [
{
id: "proj-mock-1",
name: "roboco",
slug: "roboco",
git_url: "https://github.com/rennf93/roboco.git",
default_branch: "master",
protected_branches: ["master", "slave"],
assigned_cell: Team.BACKEND,
is_active: true,
has_git_token: false,
ci_watch_enabled: true,
ci_watch_workflow: "CI",
video_engine_enabled: false,
workspace_path: "/data/workspaces/roboco",
created_by: "ceo",
created_at: "2026-06-01T00:00:00Z",
updated_at: null,
} as Project,
{
id: "proj-mock-2",
name: "roboco-website",
slug: "roboco-website",
git_url: "https://github.com/rennf93/roboco-website.git",
default_branch: "master",
protected_branches: ["master"],
assigned_cell: Team.FRONTEND,
is_active: true,
has_git_token: false,
ci_watch_enabled: false,
ci_watch_workflow: null,
video_engine_enabled: false,
workspace_path: null,
created_by: "ceo",
created_at: "2026-06-15T00:00:00Z",
updated_at: null,
} as Project,
];
// Mock task counts per project (mock-mode only — real data comes from the
// backend's grouped query). Keyed by project id.
const mockTaskCounts: Record<string, { done: number; active: number; blocked: number }> = {
"proj-mock-1": { done: 120, active: 8, blocked: 1 },
"proj-mock-2": { done: 34, active: 2, blocked: 0 },
};
export interface ProjectFilters {
assigned_cell?: Team;
@@ -41,6 +85,8 @@ export const projectsApi = {
has_workspace: !!p.workspace_path,
has_git_token: false, // Mock mode has no tokens
video_engine_enabled: p.video_engine_enabled,
ci_watch_enabled: !!p.ci_watch_enabled,
task_counts: mockTaskCounts[p.id] ?? null,
}));
}
+8
View File
@@ -1100,6 +1100,12 @@ export interface ProjectUpdate {
sandbox_extensions?: Record<string, string[]>;
}
export interface ProjectTaskCounts {
done: number;
active: number;
blocked: number;
}
export interface ProjectSummary {
id: string;
name: string;
@@ -1110,6 +1116,8 @@ export interface ProjectSummary {
has_workspace: boolean;
has_git_token: boolean;
video_engine_enabled: boolean;
ci_watch_enabled: boolean;
task_counts: ProjectTaskCounts | null;
}
export interface ProductCellMapping {
+19 -1
View File
@@ -26,6 +26,7 @@ from roboco.api.schemas.project import (
ProjectCreateRequest,
ProjectResponse,
ProjectSummaryResponse,
ProjectTaskCounts,
ProjectUpdateRequest,
SetWorkspaceRequest,
SyncStateRequest,
@@ -75,7 +76,24 @@ async def list_projects(
offset=offset,
)
return [project_to_summary(p) for p in projects]
counts = await service.task_counts_for_projects(projects)
out: list[ProjectSummaryResponse] = []
for p in projects:
pid = cast("UUID", p.id)
c = counts.get(pid)
out.append(
project_to_summary(
p,
task_counts=(
ProjectTaskCounts(
done=c["done"], active=c["active"], blocked=c["blocked"]
)
if c
else None
),
)
)
return out
@router.get("/{project_id}", response_model=ProjectResponse)
+19 -1
View File
@@ -68,12 +68,23 @@ class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ProjectTaskCounts(BaseModel):
"""Per-project task progress (done/active/blocked) for list views."""
done: int = 0
active: int = 0
blocked: int = 0
class ProjectSummaryResponse(BaseModel):
"""Compact project response for list views.
Returned by GET /api/projects; includes essential project metadata
for list-view cards. The `video_engine_enabled` field indicates
whether this project is opted in to the video engine subsystem.
`task_counts` is a done/active/blocked breakdown from one grouped
query over tasks; `ci_watch_enabled` signals CI-watch is armed (no
live-conclusion fan-out that's a deferred cached endpoint).
"""
id: UUID
@@ -86,6 +97,8 @@ class ProjectSummaryResponse(BaseModel):
has_workspace: bool = False
has_git_token: bool = False
video_engine_enabled: bool = False
ci_watch_enabled: bool = False
task_counts: ProjectTaskCounts | None = None
model_config = ConfigDict(from_attributes=True)
@@ -262,7 +275,10 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
)
def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
def project_to_summary(
project: "ProjectTable",
task_counts: "ProjectTaskCounts | None" = None,
) -> ProjectSummaryResponse:
"""Convert a ProjectTable to ProjectSummaryResponse."""
default_branch = project.default_branch
return ProjectSummaryResponse(
@@ -276,5 +292,7 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
has_workspace=bool(project.workspace_path),
has_git_token=bool(project.git_token_encrypted),
video_engine_enabled=bool(project.video_engine_enabled),
ci_watch_enabled=bool(project.ci_watch_enabled),
task_counts=task_counts,
)
+59 -3
View File
@@ -6,19 +6,28 @@ Projects represent git repositories that agents work on.
"""
from typing import ClassVar
from typing import cast as typing_cast
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.config import settings
from roboco.db.tables import ProjectTable
from roboco.db.tables import ProjectTable, TaskTable
from roboco.exceptions import ValidationError
from roboco.models.base import Team
from roboco.models.base import TaskStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
# Statuses that are NOT active progress: completed (done), cancelled
# (abandoned), blocked (its own bucket). Everything else counts as active.
_INACTIVE_STATUSES = (
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
TaskStatus.BLOCKED,
)
class ProjectService(BaseService):
"""
@@ -344,6 +353,53 @@ class ProjectService(BaseService):
result = await self.session.execute(query)
return list(result.scalars().all())
async def task_counts_for_projects(
self, projects: list[ProjectTable]
) -> dict[UUID, dict[str, int]]:
"""Per-project task progress (done/active/blocked) — one grouped query.
One ``GROUP BY project_id`` over tasks for every distinct project_id
in the list. Returns {project_id: {done, active, blocked}}; a project
with no tasks is absent (the caller falls back to zeros).
"""
project_ids = [typing_cast("UUID", p.id) for p in projects if p.id is not None]
if not project_ids:
return {}
result = await self.session.execute(
select(
TaskTable.project_id,
func.coalesce(
func.sum(
case((TaskTable.status == TaskStatus.COMPLETED, 1), else_=0)
),
0,
).label("done"),
func.coalesce(
func.sum(
case((TaskTable.status == TaskStatus.BLOCKED, 1), else_=0)
),
0,
).label("blocked"),
func.coalesce(
func.sum(
case((TaskTable.status.in_(_INACTIVE_STATUSES), 0), else_=1)
),
0,
).label("active"),
)
.where(TaskTable.project_id.in_(project_ids))
.group_by(TaskTable.project_id)
)
out: dict[UUID, dict[str, int]] = {}
for row in result.fetchall():
out[typing_cast("UUID", row.project_id)] = {
"done": int(row.done or 0),
"active": int(row.active or 0),
"blocked": int(row.blocked or 0),
}
return out
async def list_by_cell(
self,
cell: Team,
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests for ProjectService.task_counts_for_projects.
Mocks the SQLAlchemy AsyncSession.execute() boundary and verifies the
per-project task-count breakdown (one grouped query over tasks).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID
import pytest
from roboco.services.project import ProjectService
_PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
_PROJECT_2 = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
def _project(pid: UUID | None) -> MagicMock:
p = MagicMock()
p.id = pid
return p
def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
result = MagicMock()
result.fetchall = MagicMock(return_value=rows)
return result
def _row(project_id: UUID, done: int, active: int, blocked: int) -> MagicMock:
row = MagicMock()
row.project_id = project_id
row.done = done
row.active = active
row.blocked = blocked
return row
class TestTaskCountsForProjects:
@pytest.mark.asyncio
async def test_maps_per_project_counts(self) -> None:
session = MagicMock()
session.execute = AsyncMock(
return_value=_result_fetchall(
[
_row(_PROJECT_1, done=3, active=2, blocked=1),
_row(_PROJECT_2, done=5, active=0, blocked=0),
]
)
)
svc = ProjectService(session)
out = await svc.task_counts_for_projects(
[_project(_PROJECT_1), _project(_PROJECT_2)]
)
assert out[_PROJECT_1] == {"done": 3, "active": 2, "blocked": 1}
assert out[_PROJECT_2] == {"done": 5, "active": 0, "blocked": 0}
@pytest.mark.asyncio
async def test_project_with_no_tasks_absent_from_map(self) -> None:
session = MagicMock()
session.execute = AsyncMock(
return_value=_result_fetchall(
[_row(_PROJECT_1, done=1, active=0, blocked=0)]
)
)
svc = ProjectService(session)
out = await svc.task_counts_for_projects(
[_project(_PROJECT_1), _project(_PROJECT_2)]
)
# Project 2 has no task row -> absent (route falls back to None).
assert _PROJECT_1 in out
assert _PROJECT_2 not in out
@pytest.mark.asyncio
async def test_no_projects_no_query(self) -> None:
session = MagicMock()
session.execute = AsyncMock()
svc = ProjectService(session)
out = await svc.task_counts_for_projects([])
assert out == {}
session.execute.assert_not_called()
@pytest.mark.asyncio
async def test_skips_projects_without_id(self) -> None:
session = MagicMock()
session.execute = AsyncMock(
return_value=_result_fetchall(
[_row(_PROJECT_1, done=2, active=1, blocked=0)]
)
)
svc = ProjectService(session)
out = await svc.task_counts_for_projects([_project(_PROJECT_1), _project(None)])
assert out[_PROJECT_1] == {"done": 2, "active": 1, "blocked": 0}