mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user