mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[21e195cd] Panel-wide UI standardization and usability pass (#194)
* [4c179e3a] Add git pull, fetch, and rebase backend endpoints (#190) * [f966f772] feat(git): add pull, fetch, and rebase endpoints with integration tests (#185) - Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response schemas - Add GitService.pull(), fetch(), and rebase() methods using _network_git_timeout() - Add POST /api/git/pull, /api/git/fetch, /api/git/rebase route handlers - Rebase detects conflicts via git diff --name-only --diff-filter=U and aborts cleanly - Integration tests cover success path and GitCommandError→500 for all three endpoints - Rebase conflict test verifies conflict=True with populated conflicted_files list Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [26e2b7af] test(git): add AsyncMock unit tests for rebase_onto_base conflict-state handling (#186) New test_git_rebase.py covers three branches of rebase_onto_base: - success path: rebase exits 0, returns rebased status, abort never called - conflict path: non-zero exit → diff → abort → returns conflict+files - resilience: both rebase and abort exit non-zero, still returns conflict dict without exception All tests use AsyncMock with side_effect sequences to mock _run_git at the service-method level. Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> * [551b1dbf] Panel-wide frontend UI standardization and page fixes (#193) * [1ec787b2] feat(panel): design-system sweep — full-width layouts, scrollbar fix, Secretary button, component audit (#188) - Settings page: remove max-w-3xl, wrap cards in grid-cols-1 lg:grid-cols-2 two-column layout - AI Providers page: remove max-w-5xl so AIRoutingCard fills available width - Journals AgentList: replace ScrollArea with overflow-y-auto div to eliminate nested scrollbar - Secretary chat input: add items-stretch to flex row so Send/Start button matches Textarea height - Component audit: replace all raw <button>/<input>/hand-rolled badge spans outside components/ui/ with canonical Button, Checkbox, Badge variants across 15 files: - ai-routing-card.tsx: ModeButton → Button, checkbox → Checkbox, badge spans → Badge - self-hosted-section.tsx: eye-toggle → Button ghost icon-sm, badge spans → Badge - journals/agent-item.tsx, communications/channel-item.tsx → Button ghost - kb-search-bar.tsx, kb-filters.tsx → Button ghost - kb-category-nav.tsx, git-log-panel.tsx → Button ghost - communications/page.tsx (channel + group lists) → Button ghost - projects/project-table.tsx, products/product-table.tsx → Button link - git-branch-panel.tsx (local + remote lists) → Button ghost - tasks/dependency-selector.tsx: Button ghost + Checkbox for visual indicator - tasks/task-table.tsx: sortable header + expand toggle → Button ghost - business/goals-tab.tsx: hidden button → Button Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [435b37b4] feat(metrics,notifications): URL-persisted tab state, semantic chart colors, humanized counts (#187) - Notifications page: replace useState with useSearchParams/useRouter for ?tab= URL parameter (all/unread/pending, default: unread); Suspense wrapper with skeleton fallback for SSR compatibility. - Metrics page: split into Performance tab (Velocity + Task Status + Agent Status + Team Health) and Token Usage tab (TokenUsageCostsSection) with ?tab= URL parameter (default: performance); Suspense wrapper; Refresh button moved inside PerformanceTabContent; humanizeCount() helper applies K/M suffixes to all MetricCard numeric values >= 1000. - Chart components (usage-time-series, agent-usage, team-usage, model-donut): replace var(--chart-N) CSS vars with explicit semantic hex colors — #3b82f6 blue for informational, #f59e0b amber for warning/pending, #22c55e green for success/healthy, #ef4444 red for error/blocked, #a855f7 purple for supplemental. pnpm lint and pnpm typecheck pass with zero new errors. Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [ccd256f4] Kanban mobile viewport: 375px layout, column navigation, 44px touch targets (#191) * [ccd256f4] feat(kanban): mobile 375px layout with column navigator and 44px touch targets - KanbanBoard: add activeColumnIndex state + mobile prev/next column navigator (lg:hidden); existing horizontal-scroll layout hidden on mobile (hidden lg:flex). Desktop DnD behavior unchanged. - KanbanColumn: add optional className prop (cn-based) so mobile view can pass w-full/sm:w-full to fill the viewport. - KanbanCard: bump all action buttons to min-h-11 (44px) touch targets (Assign, Pass, Fail, Move-forward). * [ccd256f4] fix(kanban): change breakpoint from lg to sm for mobile/desktop layout switch AC3 requires >=640px viewport to show multi-column layout (sm: breakpoint). Previous impl used lg: (1024px), leaving 640-1023px in single-column mode. Change: - Mobile navigator div: lg:hidden → sm:hidden - Desktop multi-column div: hidden lg:flex → hidden sm:flex At <640px: single-column with prev/next navigator (375px mobile use case). At >=640px: full horizontal-scroll multi-column layout (per AC3). DnD behavior and all other layout unchanged. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [23f02af4] Agents page On-Demand section + Board composition; Overview Quick Actions visibility + Team Health Intake/Secretary (#189) * [23f02af4] feat(agents,overview): On-Demand section, Board composition fix, Intake/Secretary in Quick Actions + Team Health - agent-definitions.ts: remove AgentRole.MAIN_PM from getBoardAgents (Main PM has its own dedicated section; including it there was redundant). Add getOnDemandAgents() that catches agents not in any standard team (board/main_pm/backend/frontend/ux_ui/marketing) and not a standard cell role — surfaces prompter/intake agents that the API may return. - agents/page.tsx: import getOnDemandAgents; add a conditional 'On-Demand Agents' AgentGrid section (only rendered when the API returns at least one matching agent, e.g. the Intake interviewer). - quick-actions-bar.tsx: add 'Task Intake' button (→/prompter, Sparkles icon) and 'Secretary' button (→/business?tab=secretary, Bot icon) alongside existing quick actions so operators can reach on-demand agents from the Overview in one click. - team-health-cards.tsx: add OnDemandAgentCard sub-component (link card with On-Demand badge) and render static cards for 'Task Intake' and 'Secretary' appended after the API-driven TeamHealthCard list, giving them equal visual presence in the Team Health section. pnpm lint and pnpm typecheck pass with zero new errors. * [23f02af4] fix(agents,overview): QA revision — enum entries, QuickActions placement, On-Demand title, Board PR_REVIEWER AC3: types/index.ts AgentRole enum adds PR_REVIEWER, PROMPTER, SECRETARY. agent-selector.tsx ROLE_LABELS exhaustive Record updated accordingly. AC4: command-center.tsx QuickActionsBar moved to after Team Health section, before CEO Approval Queue and data-heavy grid rows — visible without scrolling on a 900px viewport. AC1: agents/page.tsx On-Demand AgentGrid title fixed to 'On-Demand' (was 'On-Demand Agents' in prior commit). AC2: agent-definitions.ts getBoardAgents adds explicit PR_REVIEWER inclusion and uses inclusion-based getOnDemandAgents (PROMPTER|SECRETARY roles). AC5: team-health-cards.tsx static OnDemandAgentCard implementation refined with correct fallback rendering when no API team data. AC6: pnpm lint and pnpm typecheck (src only) pass with zero new errors. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [b1c59206] Git page: Pull, Fetch, Rebase buttons wired to backend; Rebase destructive confirmation dialog (#192) * [b1c59206] feat(git): add Pull, Fetch, Rebase operations to Git page with destructive confirmation dialog for Rebase - Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response types - Add gitApi.pull(), gitApi.fetch(), gitApi.rebase() with mock stubs for /git/pull, /git/fetch, /git/rebase - Add useGitPull, useGitFetch, useGitRebase mutation hooks with cache invalidation; exported via useGitOperations - Add Pull (Download icon), Fetch (RefreshCcw icon), Rebase (GitGraph icon) buttons to GitActionsPanel - Rebase button triggers AlertDialog with destructive confirmation before calling API - Wire handlePull, handleFetch, handleRebase handlers in git-browser.tsx with toast feedback * [b1c59206] fix(git): add destructive styling and branch name to Rebase AlertDialog - Add className='border-destructive bg-destructive/5' to AlertDialogContent so the dialog container has the required red-tinted styling (AC3) - Update AlertDialogDescription to interpolate status?.current_branch so the dialog body explicitly names the branch being rebased (AC3) --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [3f305ed9] Frontend: Fix git control contract, complete Secretary restyling, and apply polish (CEO revision) (#199) * [72de8a65] fix(git): correct Pull/Fetch/Rebase types, API mocks, request fields, and toast handlers (#197) - types/git.ts: GitPullResponse and GitFetchResponse now have current_branch, has_changes, staged_files, unstaged_files, untracked_files, ahead, behind (matching backend GitStatusResponse); removed nonexistent commits_received/ refs_updated/remote fields - types/git.ts: GitRebaseRequest now uses target_branch: string (not onto?: string); GitRebaseResponse now has conflict: boolean and conflicted_files: string[] (removed branch/onto/commits_rebased); task_id made optional on all three request types - lib/api/git.ts: Updated mock returns for pull/fetch/rebase to match new types - git-actions-panel.tsx: onRebase prop now (targetBranch: string) => void; Rebase AlertDialog now contains an Input for target_branch; AlertDialogAction disabled when targetBranch empty and passes the value to onRebase - git-browser.tsx: handlePull and handleFetch toast references result.current_branch; handleRebase accepts targetBranch, sends target_branch in payload, toasts result.conflict and result.conflicted_files; no 'manual' task_id for any pull/fetch/rebase operation Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [be6a17fc] feat(ui): design-system polish — chart tokens, KB aria-label, Kanban touch targets (#196) - kb-search-bar.tsx: add aria-label="Clear search" to the clear (X) button - model-usage-donut.tsx: replace hex CHART_COLORS with var(--chart-1)…var(--chart-5) - usage-time-series-chart.tsx: replace hex stopColor/stroke with var(--chart-1)/var(--chart-2) - agent-usage-chart.tsx: Bar fill hex → var(--chart-1) - team-usage-chart.tsx: Bar fill hex → var(--chart-1) - kanban-card.tsx: min-h-11 → max-sm:min-h-11 (44px touch target mobile-only, 3 buttons) - secretary-tab.tsx: already compliant (Button + design-system tokens), no change needed Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [d62036bd] Backend: Fix git endpoint schemas, add safety gates, and unit tests (CEO revision) (#200) * [d0593fe3] feat(git): remove agent_id from schemas and add service-layer safety gates (#195) - Remove agent_id field from all 9 git request schemas (GitCreateBranchRequest, GitCheckoutRequest, GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest, GitPullRequest, GitFetchRequest, GitRebaseRequest); agent identity comes from JWT auth context - Make task_id Optional[UUID]=None in GitPullRequest, GitFetchRequest, GitRebaseRequest - Add field_validator to GitRebaseRequest rejecting target_branch starting with '-' or equal to 'master'/'main' - Add lightweight PullRequest, FetchRequest, RebaseRequest schemas for gateway layer - Add dirty-workspace check to GitService.pull() (raises ValidationError if porcelain output) - Switch GitService.pull() to --ff-only; raises ValidationError with diverged-branch message on non-zero exit - Add master/main guard to GitService.rebase() for both head_branch and target_branch - Update callers: routes/tasks.py (2x), services/task.py, tests/unit/services/test_git.py (2x) Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [a2f96961] Add role-gated rebase endpoint and unit tests (test_git_rebase.py) (#198) * [a2f96961] feat(git): add role-gated rebase endpoint and unit tests Add role-gate to POST /rebase restricting access to DEVELOPER and CELL_PM roles; add master/main protected-branch guard to GitService.rebase() before any git subprocess runs; add 4 unit tests in tests/unit/services/test_git_rebase.py covering both target-branch and head-branch REBASE_FORBIDDEN cases * [a2f96961] fix(git): invert rebase role gate, add ownership check, schema validator, and missing tests - _REBASE_ALLOWED_ROLES changed from {DEVELOPER, CELL_PM} to {CEO, CELL_PM, MAIN_PM} so developers correctly receive 403 per AC1/AC2 - rebase_branch() now verifies task ownership for non-CEO PM callers: if task_id is provided and the task is not assigned to the calling agent, returns 403/404 - GitRebaseRequest.target_branch gets a @field_validator rejecting '-' prefix names and protected branch names (main, master, develop) - GitService.pull() gains pre-flight safety gates: raises ValidationError DIRTY_TREE when staged/unstaged changes exist, DIVERGED_BRANCH when ahead > 0 and behind > 0 - test_git_rebase.py adds 9 new tests: pull() dirty-tree ValidationError, pull() diverged-branch ValidationError, pull() success path, schema validator for '-' prefix and protected names, and route-level tests confirming HTTP 403 for DEVELOPER and HTTP 200 for CELL_PM on POST /rebase * [a2f96961] fix(tests): add type annotations for tuple variables in test_git_rebase.py mypy needs explicit tuple type annotations when assigning bare tuples to variables used as mock side_effect return values — fixes var-annotated error caught by the server-side quality gate --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com> * [94015c6d] Frontend R3: Fix legacy git taskId coercion + rebase placeholder + phantom fields (#204) * [401ddb40] fix(git): remove phantom fields from GitPullRequest/GitFetchRequest and make task_id optional in write request interfaces; use taskId || undefined in git-browser.tsx handlers to avoid 422 errors when no task context is active (#201) Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [cca8d0c0] fix(git): fix rebase placeholder and surface backend error in toast (#202) git-actions-panel.tsx: change rebase target_branch Input placeholder from "e.g. main or origin/main" to "Remote ref (e.g. origin/HEAD)" so no default branch name (main/master/develop) is suggested. git-browser.tsx: import getErrorMessage from @/lib/api/client and use it in handleRebase catch block instead of the hardcoded string "Failed to rebase". getErrorMessage extracts the real detail from AxiosError.response.data.detail and falls back to a non-empty generic message, satisfying both the detail-surfacing and fallback criteria. Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [1ea0fbcb] Backend R3: Relax legacy git schemas + fix integration tests (#206) * [219c539b] Make task_id Optional in git request schemas and update service methods (#205) * [219c539b] feat(git): make task_id Optional in git schemas and add None-guards in service methods - GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest now have task_id: UUID | None = None - commit_for_task, push_for_task, create_pr_for_task, merge_pr_for_task skip ownership/state checks when task_id is None and proceed to the git operation - Added 16 unit tests in tests/unit/api/routes/test_git_optional_task_id.py covering schema validation and HTTP endpoint responses - Added 4 integration tests in tests/integration/test_git_routes.py for no-422 behaviour - All quality gates pass: ruff format, ruff check, mypy, pytest * [219c539b] fix(tests): remove unused type-ignore comments, redundant cast, and invalid agent_id kwarg in git_optional_task_id unit tests --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [de95ce94] test(git): fix 3 rebase integration tests to use non-protected target_branch (#203) - Add pm_git_client fixture (CELL_PM role) needed for the role-gated rebase endpoint - Change target_branch from 'main' to 'develop' in test_rebase_success, test_rebase_conflict, and test_rebase_git_command_error - Remove task_id from request bodies (optional field; random UUIDs trigger 404) - Switch all 3 rebase tests to use pm_git_client instead of git_client Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> * chore: ruff format test_agent_image_registry.py (unblock quality gate) --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Backend Developer 2
Backend Developer 1
Frontend Developer 1
Frontend Developer 2
Renn F
parent
e71a746499
commit
818f2ac7a6
@@ -17,6 +17,7 @@ import {
|
||||
getBackendAgents,
|
||||
getFrontendAgents,
|
||||
getUxAgents,
|
||||
getOnDemandAgents,
|
||||
} from "@/lib/agent-definitions";
|
||||
import {
|
||||
OrchestratorStatusCards,
|
||||
@@ -134,6 +135,19 @@ export default function AgentsPage() {
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={4}
|
||||
/>
|
||||
|
||||
{/* On-Demand section: Prompter/Intake and Secretary agents — only rendered
|
||||
when the API returns at least one matching agent */}
|
||||
{getOnDemandAgents(agents).length > 0 && (
|
||||
<AgentGrid
|
||||
title="On-Demand"
|
||||
agents={getOnDemandAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={4}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,13 +53,14 @@ function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListP
|
||||
{title}
|
||||
</h4>
|
||||
{items.map((channel) => (
|
||||
<button
|
||||
<Button
|
||||
key={channel.id}
|
||||
onClick={() => onSelect(channel.id)}
|
||||
variant="ghost"
|
||||
className={
|
||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors " +
|
||||
"w-full h-auto justify-start gap-2 px-3 py-2 text-sm font-normal whitespace-normal " +
|
||||
(selectedId === channel.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "hover:bg-muted")
|
||||
}
|
||||
>
|
||||
@@ -69,7 +70,7 @@ function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListP
|
||||
<Hash className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -135,13 +136,14 @@ function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
<Button
|
||||
key={group.id}
|
||||
onClick={() => onSelect(group.id)}
|
||||
variant="ghost"
|
||||
className={
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm text-left transition-colors " +
|
||||
"w-full h-auto justify-between px-3 py-2 text-sm font-normal whitespace-normal " +
|
||||
(selectedId === group.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "hover:bg-muted")
|
||||
}
|
||||
>
|
||||
@@ -152,7 +154,7 @@ function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
|
||||
<Badge variant="secondary" className="text-xs shrink-0 ml-2">
|
||||
{group.total_messages}
|
||||
</Badge>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useOrchestratorStatus } from "@/hooks/use-agents";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import {
|
||||
@@ -14,10 +16,10 @@ import {
|
||||
} from "@/hooks/use-usage";
|
||||
import { TaskStatus, Team } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import {
|
||||
UsageTimeSeriesChart,
|
||||
ModelUsageDonut,
|
||||
@@ -34,12 +36,30 @@ import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Timer,
|
||||
Coins,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
|
||||
|
||||
// ─── Humanized number formatting ─────────────────────────────────────────────
|
||||
|
||||
/** Format counts with K/M suffix for values >= 1000. */
|
||||
function humanizeCount(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Format token counts (same as humanizeCount but used for token display). */
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
@@ -51,6 +71,7 @@ interface MetricCardProps {
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricCardProps) {
|
||||
const displayValue = typeof value === "number" ? humanizeCount(value) : value;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
@@ -60,12 +81,12 @@ function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricC
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="text-2xl font-bold">{displayValue}</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
{trend && trendValue && (
|
||||
<div className={"flex items-center gap-1 mt-2 text-xs " +
|
||||
<div className={"flex items-center gap-1 mt-2 text-xs " +
|
||||
(trend === "up" ? "text-green-600" : trend === "down" ? "text-red-600" : "text-gray-500")
|
||||
}>
|
||||
<TrendingUp className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")} />
|
||||
@@ -118,7 +139,9 @@ function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: Tea
|
||||
);
|
||||
}
|
||||
|
||||
export default function MetricsPage() {
|
||||
// ─── Performance tab content ─────────────────────────────────────────────────
|
||||
|
||||
function PerformanceTabContent() {
|
||||
const { data: tasks, error: tasksError, refetch: refetchTasks } = useTasks();
|
||||
const { data: status, error: statusError, refetch: refetchStatus } = useOrchestratorStatus();
|
||||
|
||||
@@ -132,7 +155,6 @@ export default function MetricsPage() {
|
||||
refetchStatus();
|
||||
};
|
||||
|
||||
// Calculate metrics from local data
|
||||
const taskList = tasks || [];
|
||||
const agentList = status?.agents || [];
|
||||
|
||||
@@ -159,7 +181,7 @@ export default function MetricsPage() {
|
||||
const awaitingQa = taskList.filter((t) => t.status === TaskStatus.AWAITING_QA).length;
|
||||
const completed = taskList.filter((t) => t.status === TaskStatus.COMPLETED).length;
|
||||
|
||||
// Agent counts (from by_state if available, or count from agents array)
|
||||
// Agent counts
|
||||
const runningAgents = status?.by_state?.running || agentList.filter((a) => a.state === "running").length;
|
||||
const idleAgents = status?.by_state?.idle || agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
|
||||
const waitingAgents = status?.waiting_count || agentList.filter((a) => a.state === "waiting_long").length;
|
||||
@@ -170,164 +192,145 @@ export default function MetricsPage() {
|
||||
const teamTasks = taskList.filter((t) => t.team === team);
|
||||
return {
|
||||
team,
|
||||
activeTasks: teamTasks.filter((t) =>
|
||||
activeTasks: teamTasks.filter((t) =>
|
||||
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status)
|
||||
).length,
|
||||
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED).length,
|
||||
completedToday: teamTasks.filter((t) => {
|
||||
if (!t.completed_at) return false;
|
||||
const completed = new Date(t.completed_at);
|
||||
const c = new Date(t.completed_at);
|
||||
const today = new Date();
|
||||
return completed.toDateString() === today.toDateString();
|
||||
return c.toDateString() === today.toDateString();
|
||||
}).length,
|
||||
};
|
||||
});
|
||||
|
||||
if (isOffline) {
|
||||
return (
|
||||
<OfflineState
|
||||
title="Cannot Load Performance Metrics"
|
||||
description="Start the RoboCo orchestrator to view performance analytics."
|
||||
onRetry={refetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Metrics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Performance analytics and operational insights
|
||||
</p>
|
||||
{/* Velocity Metrics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Velocity</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Completed Today"
|
||||
value={completedToday}
|
||||
subtitle="Tasks finished"
|
||||
icon={<Zap className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completed This Week"
|
||||
value={completedThisWeek}
|
||||
subtitle="Rolling 7 days"
|
||||
icon={<TrendingUp className="h-4 w-4 text-blue-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Completed"
|
||||
value={completed}
|
||||
subtitle="All time"
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completion Rate"
|
||||
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
|
||||
subtitle="Of all tasks"
|
||||
icon={<Activity className="h-4 w-4 text-purple-500" />}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={refetch}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load Metrics"
|
||||
description="Start the RoboCo orchestrator to view performance analytics."
|
||||
onRetry={refetch}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Velocity Metrics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Velocity</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Completed Today"
|
||||
value={completedToday}
|
||||
subtitle="Tasks finished"
|
||||
icon={<Zap className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completed This Week"
|
||||
value={completedThisWeek}
|
||||
subtitle="Rolling 7 days"
|
||||
icon={<TrendingUp className="h-4 w-4 text-blue-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Completed"
|
||||
value={completed}
|
||||
subtitle="All time"
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completion Rate"
|
||||
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
|
||||
subtitle="Of all tasks"
|
||||
icon={<Activity className="h-4 w-4 text-purple-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Task Status */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
|
||||
<MetricCard
|
||||
title="Pending"
|
||||
value={pending}
|
||||
icon={<Clock className="h-4 w-4 text-gray-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="In Progress"
|
||||
value={inProgress}
|
||||
icon={<Activity className="h-4 w-4 text-blue-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Blocked"
|
||||
value={blocked}
|
||||
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Awaiting QA"
|
||||
value={awaitingQa}
|
||||
icon={<Timer className="h-4 w-4 text-yellow-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completed"
|
||||
value={completed}
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Status */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
|
||||
<MetricCard
|
||||
title="Pending"
|
||||
value={pending}
|
||||
icon={<Clock className="h-4 w-4 text-gray-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="In Progress"
|
||||
value={inProgress}
|
||||
icon={<Activity className="h-4 w-4 text-blue-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Blocked"
|
||||
value={blocked}
|
||||
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Awaiting QA"
|
||||
value={awaitingQa}
|
||||
icon={<Timer className="h-4 w-4 text-yellow-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completed"
|
||||
value={completed}
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Agent Status */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Agent Status</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Running"
|
||||
value={runningAgents}
|
||||
subtitle="Active agents"
|
||||
icon={<Users className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Idle"
|
||||
value={idleAgents}
|
||||
subtitle="Available"
|
||||
icon={<Users className="h-4 w-4 text-gray-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Waiting"
|
||||
value={waitingAgents}
|
||||
subtitle="Needs input"
|
||||
icon={<Clock className="h-4 w-4 text-yellow-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Errors"
|
||||
value={errorAgents}
|
||||
subtitle="Failed agents"
|
||||
icon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Status */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Agent Status</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Running"
|
||||
value={runningAgents}
|
||||
subtitle="Active agents"
|
||||
icon={<Users className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Idle"
|
||||
value={idleAgents}
|
||||
subtitle="Available"
|
||||
icon={<Users className="h-4 w-4 text-gray-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Waiting"
|
||||
value={waitingAgents}
|
||||
subtitle="Needs input"
|
||||
icon={<Clock className="h-4 w-4 text-yellow-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Errors"
|
||||
value={errorAgents}
|
||||
subtitle="Failed agents"
|
||||
icon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Health */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Team Health</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{teamMetrics.map((tm) => (
|
||||
<TeamHealthCard
|
||||
key={tm.team}
|
||||
team={tm.team}
|
||||
activeTasks={tm.activeTasks}
|
||||
blockedTasks={tm.blockedTasks}
|
||||
completedToday={tm.completedToday}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Token Usage & Costs ─────────────────────────────────── */}
|
||||
<TokenUsageCostsSection />
|
||||
</>
|
||||
)}
|
||||
{/* Team Health */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Team Health</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{teamMetrics.map((tm) => (
|
||||
<TeamHealthCard
|
||||
key={tm.team}
|
||||
team={tm.team}
|
||||
activeTasks={tm.activeTasks}
|
||||
blockedTasks={tm.blockedTasks}
|
||||
completedToday={tm.completedToday}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TOKEN USAGE & COSTS SECTION
|
||||
// =============================================================================
|
||||
// ─── Token Usage & Costs tab content ─────────────────────────────────────────
|
||||
|
||||
function TokenUsageCostsSection() {
|
||||
const { data: summary, isLoading: loadingSnap } = useUsageSummary("24h");
|
||||
@@ -343,8 +346,6 @@ function TokenUsageCostsSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Token Usage & Costs</h2>
|
||||
|
||||
{/* Row 1 — Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 2xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
@@ -411,7 +412,7 @@ function TokenUsageCostsSection() {
|
||||
<CacheEfficiencyCard cacheStats={cacheStats} isLoading={loadingCache} />
|
||||
</div>
|
||||
|
||||
{/* Row 5 — Sessions table (mock-mode only; empty in production) */}
|
||||
{/* Row 5 — Sessions table */}
|
||||
<SessionsTable data={sessions} isLoading={loadingSessions} />
|
||||
</div>
|
||||
);
|
||||
@@ -419,12 +420,6 @@ function TokenUsageCostsSection() {
|
||||
|
||||
// ─── Helper sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string;
|
||||
value: string | undefined;
|
||||
@@ -463,8 +458,6 @@ function SummaryCard({ title, value, icon, trend, isLoading }: SummaryCardProps)
|
||||
);
|
||||
}
|
||||
|
||||
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
|
||||
|
||||
interface ProjectionCardProps {
|
||||
projection: UP | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -531,3 +524,93 @@ function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type MetricsTab = "performance" | "token-usage";
|
||||
|
||||
const VALID_METRICS_TABS: MetricsTab[] = ["performance", "token-usage"];
|
||||
|
||||
function isValidMetricsTab(value: string | null): value is MetricsTab {
|
||||
return VALID_METRICS_TABS.includes(value as MetricsTab);
|
||||
}
|
||||
|
||||
// ─── Main page content (uses useSearchParams) ─────────────────────────────────
|
||||
|
||||
function MetricsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Read ?tab= from URL, default to "performance"
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: MetricsTab = isValidMetricsTab(rawTab) ? rawTab : "performance";
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Metrics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Performance analytics and operational insights
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="performance">Performance</TabsTrigger>
|
||||
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="performance" className="mt-6">
|
||||
<PerformanceTabContent />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="token-usage" className="mt-6">
|
||||
<TokenUsageCostsSection />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function MetricsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<MetricsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
useNotifications,
|
||||
useMarkNotificationRead,
|
||||
@@ -105,17 +106,34 @@ function NotificationCard({ notification, onMarkRead, onAcknowledge }: Notificat
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
// Default to Unread: the actionable view. Landing on "All" buries new
|
||||
// notifications under everything already seen.
|
||||
const [activeTab, setActiveTab] = useState<"all" | "unread" | "pending">("unread");
|
||||
|
||||
type TabValue = "all" | "unread" | "pending";
|
||||
|
||||
const VALID_TABS: TabValue[] = ["all", "unread", "pending"];
|
||||
|
||||
function isValidTab(value: string | null): value is TabValue {
|
||||
return VALID_TABS.includes(value as TabValue);
|
||||
}
|
||||
|
||||
function NotificationsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Read ?tab= from URL, default to "unread" (most actionable view)
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "unread";
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const { data, isLoading, error, refetch } = useNotifications(
|
||||
activeTab === "unread" ? { unread_only: true } :
|
||||
activeTab === "pending" ? { pending_ack_only: true } :
|
||||
undefined
|
||||
);
|
||||
|
||||
|
||||
const markRead = useMarkNotificationRead();
|
||||
const acknowledge = useAcknowledgeNotification();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
@@ -219,7 +237,7 @@ export default function NotificationsPage() {
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="unread">
|
||||
@@ -262,3 +280,46 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<NotificationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AIRoutingCard } from "@/components/settings/ai-routing-card";
|
||||
|
||||
export default function AIProvidersPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI Providers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
@@ -52,189 +52,192 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the look and feel of the panel</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select your preferred color scheme
|
||||
</p>
|
||||
{/* Cards grid — two columns on large screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the look and feel of the panel</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select your preferred color scheme
|
||||
</p>
|
||||
</div>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger className="w-auto min-w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger className="w-auto min-w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Collapsed Sidebar</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show icons only in the sidebar
|
||||
</p>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Collapsed Sidebar</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show icons only in the sidebar
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarCollapsed}
|
||||
onCheckedChange={setSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarCollapsed}
|
||||
onCheckedChange={setSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>Configure how you receive updates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Enable Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive real-time notifications from agents
|
||||
</p>
|
||||
{/* Notifications */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>Configure how you receive updates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Enable Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive real-time notifications from agents
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationsEnabled}
|
||||
onCheckedChange={setNotificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationsEnabled}
|
||||
onCheckedChange={setNotificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Sound Alerts</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play sound for important notifications
|
||||
</p>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Sound Alerts</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play sound for important notifications
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={soundEnabled}
|
||||
onCheckedChange={setSoundEnabled}
|
||||
disabled={!notificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={soundEnabled}
|
||||
onCheckedChange={setSoundEnabled}
|
||||
disabled={!notificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data & Refresh */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Data & Refresh
|
||||
</CardTitle>
|
||||
<CardDescription>Configure data fetching behavior</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto Refresh</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically refresh data periodically
|
||||
</p>
|
||||
{/* Data & Refresh */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Data & Refresh
|
||||
</CardTitle>
|
||||
<CardDescription>Configure data fetching behavior</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto Refresh</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically refresh data periodically
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Refresh Interval</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How often to fetch new data (seconds)
|
||||
</p>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Refresh Interval</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How often to fetch new data (seconds)
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={refreshInterval}
|
||||
onValueChange={setRefreshInterval}
|
||||
disabled={!autoRefresh}
|
||||
>
|
||||
<SelectTrigger className="w-auto min-w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10s</SelectItem>
|
||||
<SelectItem value="30">30s</SelectItem>
|
||||
<SelectItem value="60">1m</SelectItem>
|
||||
<SelectItem value="300">5m</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select
|
||||
value={refreshInterval}
|
||||
onValueChange={setRefreshInterval}
|
||||
disabled={!autoRefresh}
|
||||
>
|
||||
<SelectTrigger className="w-auto min-w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10s</SelectItem>
|
||||
<SelectItem value="30">30s</SelectItem>
|
||||
<SelectItem value="60">1m</SelectItem>
|
||||
<SelectItem value="300">5m</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transcript Retention (panel-tunable; persisted server-side) */}
|
||||
<TranscriptRetentionCard />
|
||||
{/* Transcript Retention (panel-tunable; persisted server-side) */}
|
||||
<TranscriptRetentionCard />
|
||||
|
||||
{/* Connection Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
Connection Info
|
||||
</CardTitle>
|
||||
<CardDescription>Backend API configuration (read-only)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>API URL</Label>
|
||||
<Input value={API_URL} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>WebSocket URL</Label>
|
||||
<Input value={WS_URL} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Connection Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
Connection Info
|
||||
</CardTitle>
|
||||
<CardDescription>Backend API configuration (read-only)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>API URL</Label>
|
||||
<Input value={API_URL} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>WebSocket URL</Label>
|
||||
<Input value={WS_URL} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
User Info
|
||||
</CardTitle>
|
||||
<CardDescription>Your account information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
|
||||
{/* User Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
User Info
|
||||
</CardTitle>
|
||||
<CardDescription>Your account information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">Renzo</p>
|
||||
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent ID: 00000000-0000-0000-0000-000000000001
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">Renzo</p>
|
||||
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent ID: 00000000-0000-0000-0000-000000000001
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -33,11 +33,14 @@ const ROLE_LABELS: Record<AgentRole, string> = {
|
||||
[AgentRole.PRODUCT_OWNER]: "Product Owner",
|
||||
[AgentRole.HEAD_MARKETING]: "Head Marketing",
|
||||
[AgentRole.AUDITOR]: "Auditor",
|
||||
[AgentRole.PR_REVIEWER]: "PR Reviewer",
|
||||
[AgentRole.MAIN_PM]: "Main PM",
|
||||
[AgentRole.CELL_PM]: "Cell PM",
|
||||
[AgentRole.DEVELOPER]: "Developer",
|
||||
[AgentRole.QA]: "QA",
|
||||
[AgentRole.DOCUMENTER]: "Documenter",
|
||||
[AgentRole.PROMPTER]: "Prompter",
|
||||
[AgentRole.SECRETARY]: "Secretary",
|
||||
};
|
||||
|
||||
export function AgentSelector({
|
||||
|
||||
@@ -341,8 +341,8 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden — just to make the refetch prop used */}
|
||||
<button type="button" className="hidden" onClick={refetch} />
|
||||
{/* Hidden — keeps the refetch prop wired up for future use */}
|
||||
<Button type="button" className="hidden" onClick={refetch} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -245,7 +245,7 @@ export function SecretaryTab() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<ChatMessages messages={messages} streaming={streaming} />
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-stretch gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Channel } from "@/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hash, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -19,12 +20,13 @@ export function ChannelItem({
|
||||
unreadCount = 0,
|
||||
}: ChannelItemProps) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors",
|
||||
"w-full h-auto justify-start gap-2 px-2 py-1.5 font-normal whitespace-normal",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -39,6 +41,6 @@ export function ChannelItem({
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,14 @@ export function CommandCenter() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Quick Actions — placed immediately after Team Health so it is
|
||||
visible without scrolling on a 900px-tall viewport, before the
|
||||
data-heavy grid rows below. */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||
<QuickActionsBar />
|
||||
</section>
|
||||
|
||||
{/* CEO Approval Queue + Strategy Signals - side-by-side on lg+ */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CeoApprovalQueue />
|
||||
@@ -99,12 +107,6 @@ export function CommandCenter() {
|
||||
isLoading={loadingActivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<section className="pt-4 border-t">
|
||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||
<QuickActionsBar />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreateTaskDialog } from "@/components/tasks/create-task-dialog";
|
||||
import { Users, Megaphone, BookOpen, Shield } from "lucide-react";
|
||||
import { Users, Megaphone, BookOpen, Shield, Sparkles, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function QuickActionsBar() {
|
||||
@@ -17,6 +17,20 @@ export function QuickActionsBar() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/prompter">
|
||||
<Button variant="outline">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Task Intake
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/business?tab=secretary">
|
||||
<Button variant="outline">
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Secretary
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/communications">
|
||||
<Button variant="outline">
|
||||
<Megaphone className="h-4 w-4 mr-2" />
|
||||
|
||||
@@ -3,12 +3,43 @@
|
||||
import { TeamHealth } from "@/types";
|
||||
import { TeamHealthCard } from "./team-health-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Sparkles, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface TeamHealthCardsProps {
|
||||
teams: TeamHealth[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** Static link-card for on-demand agents (Intake, Secretary) that are not
|
||||
* part of any standing team and therefore never appear in the API health data. */
|
||||
function OnDemandAgentCard({
|
||||
title,
|
||||
href,
|
||||
icon: Icon,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
<div className="rounded-lg border bg-card p-4 hover:bg-accent/50 transition-colors h-full flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
<span className="ml-auto text-xs rounded-full bg-secondary px-2 py-0.5 text-secondary-foreground">
|
||||
On-Demand
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamHealthCards({ teams, isLoading }: TeamHealthCardsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -20,19 +51,33 @@ export function TeamHealthCards({ teams, isLoading }: TeamHealthCardsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No team health data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hasTeams = teams && teams.length > 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-6 gap-4">
|
||||
{teams.map((health) => (
|
||||
<TeamHealthCard key={health.team} health={health} />
|
||||
))}
|
||||
{hasTeams ? (
|
||||
teams.map((health) => (
|
||||
<TeamHealthCard key={health.team} health={health} />
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-8 text-muted-foreground">
|
||||
No team health data available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Static on-demand agent cards — always visible regardless of API data */}
|
||||
<OnDemandAgentCard
|
||||
title="Task Intake"
|
||||
href="/prompter"
|
||||
icon={Sparkles}
|
||||
description="Intake interviewer — chat with CEO to draft and submit new tasks"
|
||||
/>
|
||||
<OnDemandAgentCard
|
||||
title="Secretary"
|
||||
href="/business?tab=secretary"
|
||||
icon={Bot}
|
||||
description="Secretary agent — manage business goals, pitches, and notes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ import {
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
GitCommit,
|
||||
Upload,
|
||||
@@ -22,6 +33,9 @@ import {
|
||||
GitMerge,
|
||||
RefreshCw,
|
||||
ArrowUp,
|
||||
Download,
|
||||
RefreshCcw,
|
||||
GitGraph,
|
||||
} from "lucide-react";
|
||||
|
||||
interface GitActionsPanelProps {
|
||||
@@ -33,10 +47,16 @@ interface GitActionsPanelProps {
|
||||
onPush: (force?: boolean) => void;
|
||||
onCreatePR: (title: string, body: string) => void;
|
||||
onMergePR: (prNumber: number) => void;
|
||||
onPull: () => void;
|
||||
onFetch: () => void;
|
||||
onRebase: (targetBranch: string) => void;
|
||||
isCommitting: boolean;
|
||||
isPushing: boolean;
|
||||
isCreatingPR: boolean;
|
||||
isMerging: boolean;
|
||||
isPulling: boolean;
|
||||
isFetching: boolean;
|
||||
isRebasing: boolean;
|
||||
}
|
||||
|
||||
export function GitActionsPanel({
|
||||
@@ -48,10 +68,16 @@ export function GitActionsPanel({
|
||||
onPush,
|
||||
onCreatePR,
|
||||
onMergePR,
|
||||
onPull,
|
||||
onFetch,
|
||||
onRebase,
|
||||
isCommitting,
|
||||
isPushing,
|
||||
isCreatingPR,
|
||||
isMerging,
|
||||
isPulling,
|
||||
isFetching,
|
||||
isRebasing,
|
||||
}: GitActionsPanelProps) {
|
||||
void _agentId; // Reserved for future use
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
||||
@@ -61,6 +87,7 @@ export function GitActionsPanel({
|
||||
const [prTitle, setPrTitle] = useState("");
|
||||
const [prBody, setPrBody] = useState("");
|
||||
const [mergePrNumber, setMergePrNumber] = useState("");
|
||||
const [rebaseTargetBranch, setRebaseTargetBranch] = useState("");
|
||||
|
||||
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
|
||||
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
|
||||
@@ -302,6 +329,88 @@ export function GitActionsPanel({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Pull Action */}
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
disabled={isPulling}
|
||||
onClick={onPull}
|
||||
>
|
||||
{isPulling ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Pull from Remote
|
||||
</Button>
|
||||
|
||||
{/* Fetch Action */}
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
disabled={isFetching}
|
||||
onClick={onFetch}
|
||||
>
|
||||
{isFetching ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Fetch Remote
|
||||
</Button>
|
||||
|
||||
{/* Rebase Action — destructive, requires confirmation */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
disabled={isRebasing}
|
||||
>
|
||||
{isRebasing ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<GitGraph className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Rebase onto Remote
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="border-destructive bg-destructive/5">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Rebase onto target branch?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will rewrite the commit history of branch{" "}
|
||||
<strong>{status?.current_branch}</strong> by replaying commits
|
||||
on top of the specified target branch. A force-push will be
|
||||
required afterward. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="px-6 py-2 space-y-2">
|
||||
<label className="text-sm font-medium">Target branch</label>
|
||||
<Input
|
||||
placeholder="Remote ref (e.g. origin/HEAD)"
|
||||
value={rebaseTargetBranch}
|
||||
onChange={(e) => setRebaseTargetBranch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setRebaseTargetBranch("")}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={!rebaseTargetBranch.trim()}
|
||||
onClick={() => {
|
||||
onRebase(rebaseTargetBranch.trim());
|
||||
setRebaseTargetBranch("");
|
||||
}}
|
||||
>
|
||||
Rebase
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Status Summary */}
|
||||
{status && (
|
||||
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
|
||||
|
||||
@@ -164,14 +164,15 @@ export function GitBranchPanel({
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{localBranches.map((branch) => (
|
||||
<button
|
||||
<Button
|
||||
key={branch.name}
|
||||
onClick={() => !branch.is_current && onCheckout(branch.name)}
|
||||
disabled={branch.is_current || isCheckingOut}
|
||||
variant="ghost"
|
||||
className={
|
||||
"w-full flex items-center justify-between px-2 py-1.5 rounded text-sm text-left transition-colors " +
|
||||
"w-full h-auto justify-between px-2 py-1.5 text-sm font-normal whitespace-normal " +
|
||||
(branch.is_current
|
||||
? "bg-primary/10 text-primary"
|
||||
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
|
||||
: "hover:bg-muted")
|
||||
}
|
||||
>
|
||||
@@ -188,7 +189,7 @@ export function GitBranchPanel({
|
||||
{branch.last_commit.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,16 +203,17 @@ export function GitBranchPanel({
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{remoteBranches.map((branch) => (
|
||||
<button
|
||||
<Button
|
||||
key={branch.name}
|
||||
onClick={() => onCheckout(branch.name)}
|
||||
disabled={isCheckingOut}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded text-sm text-left transition-colors hover:bg-muted"
|
||||
variant="ghost"
|
||||
className="w-full h-auto justify-start px-2 py-1.5 text-sm font-normal whitespace-normal hover:bg-muted"
|
||||
>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
{branch.name}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { GitDiffViewer } from "./git-diff-viewer";
|
||||
import { GitActionsPanel } from "./git-actions-panel";
|
||||
import { GitBranch, RefreshCw, FolderGit2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
|
||||
function GitBrowserContent() {
|
||||
const router = useRouter();
|
||||
@@ -49,7 +50,7 @@ function GitBrowserContent() {
|
||||
const { data: unstagedDiff, isLoading: loadingUnstagedDiff } = useGitDiff(projectSlug, false, undefined, !!projectSlug);
|
||||
|
||||
// Git operations
|
||||
const { commit, push, createBranch, checkout, createPR, mergePR } = useGitOperations();
|
||||
const { commit, push, createBranch, checkout, createPR, mergePR, pull, fetch, rebase } = useGitOperations();
|
||||
|
||||
// Update URL params
|
||||
const updateParams = useCallback(
|
||||
@@ -115,7 +116,7 @@ function GitBrowserContent() {
|
||||
const result = await commit.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
message,
|
||||
task_id: taskId || "manual",
|
||||
task_id: taskId || undefined,
|
||||
agent_id: "ceo",
|
||||
});
|
||||
toast.success(`Committed: ${result.commit_hash.slice(0, 7)}`);
|
||||
@@ -128,7 +129,7 @@ function GitBrowserContent() {
|
||||
try {
|
||||
const result = await push.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
task_id: taskId || "manual",
|
||||
task_id: taskId || undefined,
|
||||
agent_id: "ceo",
|
||||
force,
|
||||
});
|
||||
@@ -142,7 +143,7 @@ function GitBrowserContent() {
|
||||
try {
|
||||
const result = await createPR.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
task_id: taskId || "manual",
|
||||
task_id: taskId || undefined,
|
||||
title,
|
||||
body,
|
||||
agent_id: "ceo",
|
||||
@@ -165,7 +166,7 @@ function GitBrowserContent() {
|
||||
const result = await mergePR.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
pr_number: prNumber,
|
||||
task_id: taskId || "manual",
|
||||
task_id: taskId || undefined,
|
||||
agent_id: "ceo",
|
||||
});
|
||||
toast.success(`Merged PR #${result.pr_number} → ${result.target_branch}`);
|
||||
@@ -174,6 +175,50 @@ function GitBrowserContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
try {
|
||||
const result = await pull.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
task_id: taskId || undefined,
|
||||
});
|
||||
toast.success(`Pulled: now on ${result.current_branch}`);
|
||||
} catch {
|
||||
toast.error("Failed to pull from remote");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetch = async () => {
|
||||
try {
|
||||
const result = await fetch.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
task_id: taskId || undefined,
|
||||
});
|
||||
toast.success(`Fetched: now on ${result.current_branch}`);
|
||||
} catch {
|
||||
toast.error("Failed to fetch from remote");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebase = async (targetBranch: string) => {
|
||||
try {
|
||||
const result = await rebase.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
target_branch: targetBranch,
|
||||
task_id: taskId || undefined,
|
||||
agent_id: "ceo",
|
||||
});
|
||||
if (result.conflict) {
|
||||
toast.warning(
|
||||
`Rebase conflicts in: ${result.conflicted_files.join(", ") || "unknown files"}`
|
||||
);
|
||||
} else {
|
||||
toast.success("Rebase completed successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
// Check offline
|
||||
const isOffline = projectsError && (
|
||||
projectsError.message?.includes("Network Error") ||
|
||||
@@ -258,10 +303,16 @@ function GitBrowserContent() {
|
||||
onPush={handlePush}
|
||||
onCreatePR={handleCreatePR}
|
||||
onMergePR={handleMergePR}
|
||||
onPull={handlePull}
|
||||
onFetch={handleFetch}
|
||||
onRebase={handleRebase}
|
||||
isCommitting={commit.isPending}
|
||||
isPushing={push.isPending}
|
||||
isCreatingPR={createPR.isPending}
|
||||
isMerging={mergePR.isPending}
|
||||
isPulling={pull.isPending}
|
||||
isFetching={fetch.isPending}
|
||||
isRebasing={rebase.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { GitLogResponse, CommitInfo } from "@/types/git";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GitCommit, User, Calendar } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
@@ -68,15 +70,14 @@ export function GitLogPanel({
|
||||
<ScrollArea className="h-80">
|
||||
<div className="p-4 space-y-0">
|
||||
{log.commits.map((commit, index) => (
|
||||
<button
|
||||
<Button
|
||||
key={commit.hash}
|
||||
onClick={() => onSelectCommit?.(commit)}
|
||||
className={
|
||||
"w-full text-left p-3 rounded-lg transition-colors relative " +
|
||||
(selectedHash === commit.hash
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-muted")
|
||||
}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full h-auto justify-start text-left p-3 font-normal whitespace-normal relative",
|
||||
selectedHash === commit.hash ? "bg-primary/10 hover:bg-primary/10" : ""
|
||||
)}
|
||||
>
|
||||
{/* Timeline line */}
|
||||
{index < log.commits.length - 1 && (
|
||||
@@ -123,7 +124,7 @@ export function GitLogPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Agent } from "@/types";
|
||||
import { User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AgentItemProps {
|
||||
agent: Agent;
|
||||
@@ -14,12 +15,13 @@ interface AgentItemProps {
|
||||
|
||||
export function AgentItem({ agent, isSelected, onClick, hasEntries }: AgentItemProps) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2 rounded-lg text-left transition-colors",
|
||||
"w-full h-auto justify-start gap-3 p-2 font-normal whitespace-normal",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
? "bg-primary/10 border border-primary/30 hover:bg-primary/10"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
@@ -37,6 +39,6 @@ export function AgentItem({ agent, isSelected, onClick, hasEntries }: AgentItemP
|
||||
{agent.role.replace(/_/g, " ")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Agent, Team, AgentRole } from "@/types";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AgentItem } from "./agent-item";
|
||||
|
||||
@@ -84,7 +83,7 @@ export function AgentList({
|
||||
const grouped = groupByTeam(agents);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
<div className="p-2 space-y-4">
|
||||
{Object.entries(grouped).map(([teamKey, teamAgents]) => {
|
||||
if (teamAgents.length === 0) return null;
|
||||
@@ -108,6 +107,6 @@ export function AgentList({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -64,6 +64,7 @@ export function KanbanBoard({
|
||||
const updateTask = useUpdateTask();
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [pendingNotesAction, setPendingNotesAction] = useState<PendingNotesAction | null>(null);
|
||||
const [activeColumnIndex, setActiveColumnIndex] = useState(0);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -293,7 +294,56 @@ export function KanbanBoard({
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{/* Mobile: single-column view with prev/next navigator (hidden on sm+) */}
|
||||
<div className="sm:hidden">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0"
|
||||
onClick={() => setActiveColumnIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={activeColumnIndex === 0}
|
||||
aria-label="Previous column"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<p className="flex-1 text-center text-sm font-semibold">
|
||||
<span className="text-muted-foreground font-normal text-xs mr-1">
|
||||
{activeColumnIndex + 1}/{columns.length}
|
||||
</span>
|
||||
{columns[activeColumnIndex]?.title}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0"
|
||||
onClick={() =>
|
||||
setActiveColumnIndex((i) => Math.min(columns.length - 1, i + 1))
|
||||
}
|
||||
disabled={activeColumnIndex === columns.length - 1}
|
||||
aria-label="Next column"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{columns[activeColumnIndex] && (
|
||||
<KanbanColumn
|
||||
key={columns[activeColumnIndex].id}
|
||||
id={columns[activeColumnIndex].id}
|
||||
title={columns[activeColumnIndex].title}
|
||||
status={columns[activeColumnIndex].status}
|
||||
tasks={tasksByStatus[columns[activeColumnIndex].status] || []}
|
||||
color={columns[activeColumnIndex].color}
|
||||
isLoading={isLoading}
|
||||
onAction={handleAction}
|
||||
showQaActions={showQaActions}
|
||||
className="w-full sm:w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: horizontal scrolling layout (shown at sm+, i.e. >= 640px) */}
|
||||
<div className="hidden sm:flex gap-4 overflow-x-auto pb-4">
|
||||
{columns.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
|
||||
@@ -183,7 +183,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-muted-foreground hover:text-foreground"
|
||||
className="max-sm:min-h-11 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isBacklog}
|
||||
>
|
||||
@@ -210,7 +210,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
className="max-sm:min-h-11 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction("pass-qa", task.id);
|
||||
@@ -222,7 +222,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
className="max-sm:min-h-11 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction("fail-qa", task.id);
|
||||
@@ -238,7 +238,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
className="h-11 w-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction("move-forward", task.id);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { KanbanCard } from "./kanban-card";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
@@ -16,6 +17,8 @@ interface KanbanColumnProps {
|
||||
isLoading: boolean;
|
||||
onAction?: (action: string, taskId: string) => void;
|
||||
showQaActions?: boolean;
|
||||
/** Extra Tailwind classes forwarded to the root element (e.g. w-full for mobile). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
@@ -27,6 +30,7 @@ export function KanbanColumn({
|
||||
isLoading,
|
||||
onAction,
|
||||
showQaActions,
|
||||
className,
|
||||
}: KanbanColumnProps) {
|
||||
void _id; // Reserved for future use
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -36,9 +40,12 @@ export function KanbanColumn({
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex flex-col rounded-lg p-3 w-72 shrink-0 sm:w-80 ${color} ${
|
||||
isOver ? "ring-2 ring-primary ring-offset-2" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg p-3 w-72 shrink-0 sm:w-80",
|
||||
color,
|
||||
isOver && "ring-2 ring-primary ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-sm text-gray-800 dark:text-gray-100">{title}</h3>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { KBIndexType, KBStats } from "@/types";
|
||||
import { FileText, MessageSquare, BookOpen, ChevronRight, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const categoryConfig: Record<KBIndexType, { label: string; description: string; icon: React.ReactNode }> = {
|
||||
[KBIndexType.DOCUMENTATION]: {
|
||||
@@ -90,17 +92,19 @@ export function KBCategoryNav({
|
||||
const isSelected = selectedCategory === type;
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => onSelectCategory(type)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left ${
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full h-auto justify-start gap-3 p-3 font-normal whitespace-normal",
|
||||
isSelected
|
||||
? "bg-primary/10 border-primary"
|
||||
: "hover:bg-muted/50 border-transparent hover:border-border"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">{config.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{config.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -109,8 +113,8 @@ export function KBCategoryNav({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{config.description}</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${isSelected ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
<ChevronRight className={cn("h-4 w-4 text-muted-foreground shrink-0 transition-transform", isSelected && "rotate-90")} />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { KBIndexType } from "@/types";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText, MessageSquare, BookOpen, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
|
||||
|
||||
const indexTypeConfig: Record<KBIndexType, { label: string; icon: React.ReactNode }> = {
|
||||
@@ -36,12 +37,14 @@ export function KBFilters({ selectedTypes, onTypesChange }: KBFiltersProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter by type</span>
|
||||
{selectedTypes.length > 0 && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onTypesChange([])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
className="h-auto py-0 px-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -64,12 +64,15 @@ export function KBSearchBar({
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{onSearch && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ModelUsageSlice } from "@/types";
|
||||
|
||||
// Design-system chart tokens — resolves to theme-aware palette
|
||||
const CHART_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
|
||||
@@ -66,7 +66,7 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="Tokens" fill="var(--chart-2)" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="Tokens" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
@@ -60,12 +60,13 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
|
||||
<TableRow key={product.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setEditingProductId(product.id)}
|
||||
className="font-medium hover:underline text-left"
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium text-foreground"
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground font-mono">{product.slug}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -129,12 +129,13 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setEditingProjectId(project.id)}
|
||||
className="font-medium hover:underline text-left"
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium text-foreground"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{project.slug}
|
||||
</p>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { toast } from "sonner";
|
||||
import { AssignmentScope, ModelProvider } from "@/types";
|
||||
import type { RoutingMode, SelfHostedTestResult } from "@/lib/api/providers";
|
||||
import { SelfHostedSection } from "@/components/settings/self-hosted-section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
|
||||
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
|
||||
@@ -254,13 +256,13 @@ export function AIRoutingCard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Ollama Cloud API key</Label>
|
||||
{hasOllamaKey ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
||||
<KeyRound className="h-3 w-3" /> key set
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-600">
|
||||
<Badge className="bg-amber-500/10 text-amber-600 border-0">
|
||||
<Key className="h-3 w-3" /> not set
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -278,13 +280,13 @@ export function AIRoutingCard() {
|
||||
</Button>
|
||||
</div>
|
||||
{hasOllamaKey ? (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={clearKey}
|
||||
onChange={(e) => {
|
||||
setClearKey(e.target.checked);
|
||||
if (e.target.checked) setApiKey("");
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked === true;
|
||||
setClearKey(next);
|
||||
if (next) setApiKey("");
|
||||
}}
|
||||
/>
|
||||
Clear the stored key
|
||||
@@ -543,29 +545,27 @@ function ModeButton({
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={
|
||||
"rounded-md border p-3 text-left transition-colors " +
|
||||
(active || highlight
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted") +
|
||||
(disabled ? " cursor-not-allowed opacity-50" : "")
|
||||
"h-auto flex-col items-start justify-start p-3 whitespace-normal " +
|
||||
(active || highlight ? "border-primary bg-primary/5" : "")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="flex w-full items-center gap-2 text-sm font-medium">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{active ? (
|
||||
<span className="ml-auto rounded-full bg-primary/15 px-2 py-0.5 text-xs text-primary">
|
||||
<Badge className="ml-auto bg-primary/15 text-primary border-0 text-xs">
|
||||
active
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
</button>
|
||||
<p className="mt-1 text-xs text-muted-foreground font-normal">{description}</p>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,14 +146,14 @@ export function SelfHostedSection({
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">Self-Hosted LLM</Label>
|
||||
{testResult?.ok === true && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
||||
<CheckCircle2 className="h-3 w-3" /> connected
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{testResult?.ok === false && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-600">
|
||||
<Badge className="bg-red-500/10 text-red-600 border-0">
|
||||
<XCircle className="h-3 w-3" /> error
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -194,10 +194,12 @@ export function SelfHostedSection({
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showToken ? (
|
||||
@@ -205,7 +207,7 @@ export function SelfHostedSection({
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={saveConfig.isPending}>
|
||||
{saveConfig.isPending ? "Saving…" : "Save"}
|
||||
@@ -243,17 +245,17 @@ export function SelfHostedSection({
|
||||
|
||||
{/* Inline result badge */}
|
||||
{testResult?.ok === true && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-400">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-0 px-3 py-1 text-xs">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Connected — {testResult.model_count ?? 0} model
|
||||
{(testResult.model_count ?? 0) === 1 ? "" : "s"} available
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{testResult?.ok === false && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-3 py-1 text-xs font-medium text-red-700 dark:text-red-400">
|
||||
<Badge className="bg-red-500/10 text-red-700 dark:text-red-400 border-0 px-3 py-1 text-xs">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{testResult.error ?? "Connection failed"}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Search, X, Link2, Check } from "lucide-react";
|
||||
import { Search, X, Link2 } from "lucide-react";
|
||||
|
||||
interface DependencySelectorProps {
|
||||
selectedIds: string[];
|
||||
@@ -114,22 +115,22 @@ export function DependencySelector({
|
||||
{filteredTasks.map((task) => {
|
||||
const isSelected = selectedIds.includes(task.id);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={task.id}
|
||||
type="button"
|
||||
className={`w-full flex items-center gap-2 p-2 rounded-md text-left hover:bg-muted transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
variant="ghost"
|
||||
className={`w-full h-auto justify-start gap-2 p-2 font-normal whitespace-normal ${
|
||||
isSelected ? "bg-primary/10 hover:bg-primary/10" : ""
|
||||
}`}
|
||||
onClick={() => toggleTask(task.id)}
|
||||
>
|
||||
<div
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 ${
|
||||
isSelected ? "bg-primary border-primary" : "border-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
@@ -138,7 +139,7 @@ export function DependencySelector({
|
||||
<span className="capitalize">{task.status.replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -180,15 +180,16 @@ function SortableHeader({ label, field, sortConfig, onSort, className }: Sortabl
|
||||
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => onSort(field)}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors -ml-2 px-2 py-1 rounded hover:bg-muted"
|
||||
variant="ghost"
|
||||
className="h-auto -ml-2 px-2 py-1 gap-1 font-normal"
|
||||
>
|
||||
{label}
|
||||
{!isActive && <ArrowUpDown className="h-4 w-4 text-muted-foreground" />}
|
||||
{direction === "asc" && <ArrowUp className="h-4 w-4" />}
|
||||
{direction === "desc" && <ArrowDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</Button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
@@ -479,16 +480,18 @@ export function TaskTable({
|
||||
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => toggleExpand(task.id)}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="p-0.5 h-5 w-5 shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,12 @@ import type {
|
||||
GitCreatePRResponse,
|
||||
GitMergePRRequest,
|
||||
GitMergePRResponse,
|
||||
GitPullRequest,
|
||||
GitPullResponse,
|
||||
GitFetchRequest,
|
||||
GitFetchResponse,
|
||||
GitRebaseRequest,
|
||||
GitRebaseResponse,
|
||||
} from "@/types/git";
|
||||
|
||||
// =============================================================================
|
||||
@@ -226,6 +232,60 @@ export function useMergePR() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull latest changes from remote
|
||||
*/
|
||||
export function useGitPull() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<GitPullResponse, Error, GitPullRequest>({
|
||||
mutationFn: (request) => gitApi.pull(request),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate status and log after pull
|
||||
queryClient.invalidateQueries({ queryKey: gitKeys.status(variables.project_slug) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...gitKeys.all, "log", variables.project_slug],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch refs from remote without merging
|
||||
*/
|
||||
export function useGitFetch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<GitFetchResponse, Error, GitFetchRequest>({
|
||||
mutationFn: (request) => gitApi.fetch(request),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate status after fetch
|
||||
queryClient.invalidateQueries({ queryKey: gitKeys.status(variables.project_slug) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebase current branch onto remote (destructive — rewrites history)
|
||||
*/
|
||||
export function useGitRebase() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<GitRebaseResponse, Error, GitRebaseRequest>({
|
||||
mutationFn: (request) => gitApi.rebase(request),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate everything after rebase since history has changed
|
||||
queryClient.invalidateQueries({ queryKey: gitKeys.status(variables.project_slug) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...gitKeys.all, "log", variables.project_slug],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...gitKeys.all, "diff", variables.project_slug],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bundled Hook for Git Operations
|
||||
// =============================================================================
|
||||
@@ -240,6 +300,9 @@ export function useGitOperations() {
|
||||
const checkout = useCheckout();
|
||||
const createPR = useCreatePR();
|
||||
const mergePR = useMergePR();
|
||||
const pull = useGitPull();
|
||||
const fetch = useGitFetch();
|
||||
const rebase = useGitRebase();
|
||||
|
||||
return {
|
||||
commit,
|
||||
@@ -248,5 +311,8 @@ export function useGitOperations() {
|
||||
checkout,
|
||||
createPR,
|
||||
mergePR,
|
||||
pull,
|
||||
fetch,
|
||||
rebase,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,12 +23,14 @@ export const getBoardAgents = (agents: AgentDefinition[] | undefined | null) =>
|
||||
(a) =>
|
||||
// The CEO is the human operator, not a spawnable agent — exclude it even
|
||||
// though its record carries team=board.
|
||||
// MAIN_PM has its own dedicated section below Board, so exclude it here.
|
||||
a.role !== AgentRole.CEO &&
|
||||
a.role !== AgentRole.MAIN_PM &&
|
||||
(a.team === Team.BOARD ||
|
||||
a.role === AgentRole.HEAD_MARKETING ||
|
||||
a.role === AgentRole.AUDITOR ||
|
||||
a.role === AgentRole.PRODUCT_OWNER ||
|
||||
a.role === AgentRole.MAIN_PM)
|
||||
a.role === AgentRole.PR_REVIEWER)
|
||||
);
|
||||
|
||||
export const getMainPm = (agents: AgentDefinition[] | undefined | null) =>
|
||||
@@ -45,3 +47,14 @@ export const getUxAgents = (agents: AgentDefinition[] | undefined | null) =>
|
||||
|
||||
export const getMarketingAgents = (agents: AgentDefinition[] | undefined | null) =>
|
||||
(agents ?? []).filter((a) => a.team === Team.MARKETING);
|
||||
|
||||
// On-demand agents (Prompter/Intake, Secretary) are not part of any standing
|
||||
// cell team — they are spawned on request. Captured by inclusion of their
|
||||
// explicit roles so new on-demand agents appear automatically when roles
|
||||
// are added to the enum.
|
||||
export const getOnDemandAgents = (agents: AgentDefinition[] | undefined | null) =>
|
||||
(agents ?? []).filter(
|
||||
(a) =>
|
||||
a.role === AgentRole.PROMPTER ||
|
||||
a.role === AgentRole.SECRETARY
|
||||
);
|
||||
|
||||
@@ -23,6 +23,12 @@ import type {
|
||||
GitCreatePRResponse,
|
||||
GitMergePRRequest,
|
||||
GitMergePRResponse,
|
||||
GitPullRequest,
|
||||
GitPullResponse,
|
||||
GitFetchRequest,
|
||||
GitFetchResponse,
|
||||
GitRebaseRequest,
|
||||
GitRebaseResponse,
|
||||
} from "@/types/git";
|
||||
|
||||
// =============================================================================
|
||||
@@ -152,7 +158,7 @@ export const gitApi = {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
commit_hash: "abc123456789abcdef",
|
||||
message: `[${request.task_id.slice(0, 8)}] ${request.message}`,
|
||||
message: `[${request.task_id?.slice(0, 8) ?? "manual"}] ${request.message}`,
|
||||
files_changed: request.files?.length || 1,
|
||||
insertions: 10,
|
||||
deletions: 5,
|
||||
@@ -239,4 +245,56 @@ export const gitApi = {
|
||||
const { data } = await api.post<GitMergePRResponse>("/git/pr/merge", request);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pull latest changes from remote
|
||||
*/
|
||||
pull: async (request: GitPullRequest): Promise<GitPullResponse> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
current_branch: "feature/backend/abc12345",
|
||||
has_changes: false,
|
||||
staged_files: [],
|
||||
unstaged_files: [],
|
||||
untracked_files: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<GitPullResponse>("/git/pull", request);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch refs from remote without merging
|
||||
*/
|
||||
fetch: async (request: GitFetchRequest): Promise<GitFetchResponse> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
current_branch: "feature/backend/abc12345",
|
||||
has_changes: false,
|
||||
staged_files: [],
|
||||
unstaged_files: [],
|
||||
untracked_files: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<GitFetchResponse>("/git/fetch", request);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rebase current branch onto target branch (destructive — rewrites history)
|
||||
*/
|
||||
rebase: async (request: GitRebaseRequest): Promise<GitRebaseResponse> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
conflict: false,
|
||||
conflicted_files: [],
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<GitRebaseResponse>("/git/rebase", request);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
+46
-4
@@ -102,14 +102,14 @@ export interface GitMergePRResponse {
|
||||
export interface GitCommitRequest {
|
||||
project_slug: string;
|
||||
message: string;
|
||||
task_id: string;
|
||||
task_id?: string;
|
||||
agent_id: string;
|
||||
files?: string[] | null;
|
||||
}
|
||||
|
||||
export interface GitPushRequest {
|
||||
project_slug: string;
|
||||
task_id: string;
|
||||
task_id?: string;
|
||||
agent_id: string;
|
||||
force?: boolean;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export interface GitCheckoutRequest {
|
||||
|
||||
export interface GitCreatePRRequest {
|
||||
project_slug: string;
|
||||
task_id: string;
|
||||
task_id?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
agent_id: string;
|
||||
@@ -143,7 +143,49 @@ export type MergeMethod = "merge" | "squash" | "rebase";
|
||||
export interface GitMergePRRequest {
|
||||
project_slug: string;
|
||||
pr_number: number;
|
||||
task_id: string;
|
||||
task_id?: string;
|
||||
merge_method?: MergeMethod;
|
||||
agent_id: string;
|
||||
}
|
||||
|
||||
export interface GitPullRequest {
|
||||
project_slug: string;
|
||||
task_id?: string;
|
||||
}
|
||||
|
||||
export interface GitPullResponse {
|
||||
current_branch: string;
|
||||
has_changes: boolean;
|
||||
staged_files: string[];
|
||||
unstaged_files: string[];
|
||||
untracked_files: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export interface GitFetchRequest {
|
||||
project_slug: string;
|
||||
task_id?: string;
|
||||
}
|
||||
|
||||
export interface GitFetchResponse {
|
||||
current_branch: string;
|
||||
has_changes: boolean;
|
||||
staged_files: string[];
|
||||
unstaged_files: string[];
|
||||
untracked_files: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export interface GitRebaseRequest {
|
||||
project_slug: string;
|
||||
target_branch: string;
|
||||
task_id?: string;
|
||||
agent_id?: string;
|
||||
}
|
||||
|
||||
export interface GitRebaseResponse {
|
||||
conflict: boolean;
|
||||
conflicted_files: string[];
|
||||
}
|
||||
|
||||
@@ -34,11 +34,14 @@ export enum AgentRole {
|
||||
PRODUCT_OWNER = "product_owner",
|
||||
HEAD_MARKETING = "head_marketing",
|
||||
AUDITOR = "auditor",
|
||||
PR_REVIEWER = "pr_reviewer",
|
||||
MAIN_PM = "main_pm",
|
||||
CELL_PM = "cell_pm",
|
||||
DEVELOPER = "developer",
|
||||
QA = "qa",
|
||||
DOCUMENTER = "documenter",
|
||||
PROMPTER = "prompter",
|
||||
SECRETARY = "secretary",
|
||||
}
|
||||
|
||||
export enum AgentState {
|
||||
|
||||
@@ -41,15 +41,22 @@ from roboco.api.schemas.git import (
|
||||
GitCreatePRRequest,
|
||||
GitCreatePRResponse,
|
||||
GitDiffResponse,
|
||||
GitFetchRequest,
|
||||
GitFetchResponse,
|
||||
GitLogResponse,
|
||||
GitMergePRRequest,
|
||||
GitMergePRResponse,
|
||||
GitPullRequest,
|
||||
GitPullResponse,
|
||||
GitPushRequest,
|
||||
GitPushResponse,
|
||||
GitRebaseRequest,
|
||||
GitRebaseResponse,
|
||||
GitStatusResponse,
|
||||
)
|
||||
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
|
||||
from roboco.logging import get_logger
|
||||
from roboco.models.base import AgentRole
|
||||
from roboco.services.base import (
|
||||
NotFoundError,
|
||||
ServiceError,
|
||||
@@ -58,6 +65,7 @@ from roboco.services.base import (
|
||||
)
|
||||
from roboco.services.git import get_git_service
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -73,6 +81,16 @@ _LOG_FORMAT_PARTS = 5
|
||||
# bubbling as 500 Internal Server Errors with no `detail`.
|
||||
_TranslatableError = (ServiceError, GitError)
|
||||
|
||||
# Roles permitted to rebase branches via the /rebase endpoint.
|
||||
# Rebase is a history-rewriting operation that should be authorised only by
|
||||
# PM-level or CEO-level callers. Developers are intentionally excluded:
|
||||
# they commit to their feature branch and let PMs/CEO manage integration
|
||||
# rebases. This gate prevents developers from accidentally force-rewriting
|
||||
# shared branch history.
|
||||
_REBASE_ALLOWED_ROLES: frozenset[AgentRole] = frozenset(
|
||||
{AgentRole.CEO, AgentRole.CELL_PM, AgentRole.MAIN_PM}
|
||||
)
|
||||
|
||||
|
||||
def _translate_error(e: ServiceError | GitError) -> HTTPException:
|
||||
"""Translate service errors to HTTP exceptions."""
|
||||
@@ -472,3 +490,138 @@ async def merge_pull_request(
|
||||
merge_commit=merge_commit,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/pull", response_model=GitPullResponse)
|
||||
async def pull_commits(
|
||||
data: GitPullRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> GitPullResponse:
|
||||
"""Pull latest changes from origin into the agent workspace."""
|
||||
project_slug = await _resolve_project_slug(data.project_slug, db)
|
||||
git_service = get_git_service(db)
|
||||
|
||||
try:
|
||||
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||
(
|
||||
current_branch,
|
||||
has_changes,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
ahead,
|
||||
behind,
|
||||
) = await git_service.pull(workspace)
|
||||
except _TranslatableError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
return GitPullResponse(
|
||||
project_slug=project_slug,
|
||||
current_branch=current_branch,
|
||||
has_changes=has_changes,
|
||||
staged_files=staged,
|
||||
unstaged_files=unstaged,
|
||||
untracked_files=untracked,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fetch", response_model=GitFetchResponse)
|
||||
async def fetch_commits(
|
||||
data: GitFetchRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> GitFetchResponse:
|
||||
"""Fetch changes from origin without merging."""
|
||||
project_slug = await _resolve_project_slug(data.project_slug, db)
|
||||
git_service = get_git_service(db)
|
||||
|
||||
try:
|
||||
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||
(
|
||||
current_branch,
|
||||
has_changes,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
ahead,
|
||||
behind,
|
||||
) = await git_service.fetch(workspace)
|
||||
except _TranslatableError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
return GitFetchResponse(
|
||||
project_slug=project_slug,
|
||||
current_branch=current_branch,
|
||||
has_changes=has_changes,
|
||||
staged_files=staged,
|
||||
unstaged_files=unstaged,
|
||||
untracked_files=untracked,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/rebase", response_model=GitRebaseResponse)
|
||||
async def rebase_branch(
|
||||
data: GitRebaseRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> GitRebaseResponse:
|
||||
"""Rebase the current branch onto target_branch.
|
||||
|
||||
Role-gated: only CEO and PM roles (cell_pm, main_pm) may rebase branches.
|
||||
Developers, QA, documenters, and other roles are rejected with 403.
|
||||
|
||||
If task_id is provided and the caller is not CEO, the task's assigned_to
|
||||
is checked: if the task is not assigned to the calling agent, 403 is
|
||||
returned (or 404 if the task does not exist).
|
||||
|
||||
On conflict: aborts the rebase and returns conflict=True with the
|
||||
list of conflicted files. On success: returns conflict=False.
|
||||
"""
|
||||
if agent.role not in _REBASE_ALLOWED_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
f"REBASE_ROLE_RESTRICTED: Role '{agent.role}' is not permitted "
|
||||
"to rebase. Only CEO and PM roles (cell_pm, main_pm) may use "
|
||||
"this endpoint."
|
||||
),
|
||||
)
|
||||
# Task ownership check: if a task_id is supplied and the caller is not CEO,
|
||||
# ensure the task is assigned to the calling agent.
|
||||
if data.task_id is not None and agent.role != AgentRole.CEO:
|
||||
task_service = get_task_service(db)
|
||||
task = await task_service.get(data.task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {data.task_id}",
|
||||
)
|
||||
if task.assigned_to != agent.agent_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"REBASE_OWNERSHIP_RESTRICTED: This task is not assigned to "
|
||||
"you. Only the task's assigned agent or CEO may rebase it."
|
||||
),
|
||||
)
|
||||
project_slug = await _resolve_project_slug(data.project_slug, db)
|
||||
git_service = get_git_service(db)
|
||||
|
||||
try:
|
||||
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||
conflict, conflicted_files = await git_service.rebase(
|
||||
workspace, data.target_branch
|
||||
)
|
||||
except _TranslatableError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
return GitRebaseResponse(
|
||||
project_slug=project_slug,
|
||||
conflict=conflict,
|
||||
conflicted_files=conflicted_files,
|
||||
)
|
||||
|
||||
@@ -228,7 +228,6 @@ async def _merge_pr_if_awaiting_pm_review(
|
||||
pr_number=pre_task.pr_number,
|
||||
task_id=task_id,
|
||||
merge_method="squash",
|
||||
agent_id=str(agent.agent_id),
|
||||
),
|
||||
)
|
||||
except (ServiceError, GitError) as e:
|
||||
@@ -1809,7 +1808,6 @@ async def approve_and_merge_task(
|
||||
pr_number=task.pr_number,
|
||||
task_id=task_id,
|
||||
merge_method="squash",
|
||||
agent_id=str(agent.agent_id),
|
||||
),
|
||||
)
|
||||
except (ServiceError, GitError) as e:
|
||||
|
||||
+144
-11
@@ -7,7 +7,7 @@ Request/response models for git operation endpoints.
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# =============================================================================
|
||||
# STATUS
|
||||
@@ -78,7 +78,6 @@ class GitCreateBranchRequest(BaseModel):
|
||||
project_slug: str
|
||||
task_id: UUID
|
||||
branch_type: str = Field(..., pattern=r"^(feature|bug|chore|docs|hotfix)$")
|
||||
agent_id: str
|
||||
parent_branch: str | None = None
|
||||
|
||||
|
||||
@@ -95,7 +94,6 @@ class GitCheckoutRequest(BaseModel):
|
||||
|
||||
project_slug: str
|
||||
branch: str
|
||||
agent_id: str
|
||||
|
||||
|
||||
class GitCheckoutResponse(BaseModel):
|
||||
@@ -129,8 +127,7 @@ class GitCommitRequest(BaseModel):
|
||||
"""Request to create a commit."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID
|
||||
agent_id: str
|
||||
task_id: UUID | None = None
|
||||
# Commit message fields
|
||||
message: str = Field(
|
||||
...,
|
||||
@@ -168,8 +165,7 @@ class GitPushRequest(BaseModel):
|
||||
"""Request to push commits."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID
|
||||
agent_id: str
|
||||
task_id: UUID | None = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
@@ -191,8 +187,7 @@ class GitCreatePRRequest(BaseModel):
|
||||
"""Request to create a pull request."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID
|
||||
agent_id: str
|
||||
task_id: UUID | None = None
|
||||
# PR content (auto-generated from templates if not provided)
|
||||
title: str | None = Field(None, description="PR title (auto-generated if not set)")
|
||||
body: str | None = Field(None, description="PR body (auto-generated if not set)")
|
||||
@@ -218,9 +213,8 @@ class GitMergePRRequest(BaseModel):
|
||||
|
||||
project_slug: str
|
||||
pr_number: int
|
||||
task_id: UUID
|
||||
task_id: UUID | None = None
|
||||
merge_method: str = Field(default="squash", pattern=r"^(merge|squash|rebase)$")
|
||||
agent_id: str
|
||||
|
||||
|
||||
class GitMergePRResponse(BaseModel):
|
||||
@@ -230,3 +224,142 @@ class GitMergePRResponse(BaseModel):
|
||||
merged: bool
|
||||
merge_commit: str | None = None
|
||||
target_branch: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PULL
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GitPullRequest(BaseModel):
|
||||
"""Request to pull latest changes from origin."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID | None = None
|
||||
|
||||
|
||||
class GitPullResponse(BaseModel):
|
||||
"""Response from git pull — branch status after the pull."""
|
||||
|
||||
project_slug: str
|
||||
current_branch: str
|
||||
has_changes: bool
|
||||
staged_files: list[str] = []
|
||||
unstaged_files: list[str] = []
|
||||
untracked_files: list[str] = []
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FETCH
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GitFetchRequest(BaseModel):
|
||||
"""Request to fetch changes from origin without merging."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID | None = None
|
||||
|
||||
|
||||
class GitFetchResponse(BaseModel):
|
||||
"""Response from git fetch — branch status after the fetch."""
|
||||
|
||||
project_slug: str
|
||||
current_branch: str
|
||||
has_changes: bool
|
||||
staged_files: list[str] = []
|
||||
unstaged_files: list[str] = []
|
||||
untracked_files: list[str] = []
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REBASE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GitRebaseRequest(BaseModel):
|
||||
"""Request to rebase the current branch onto a target branch."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID | None = None
|
||||
target_branch: str
|
||||
|
||||
@field_validator("target_branch")
|
||||
@classmethod
|
||||
def _validate_target_branch(cls, v: str) -> str:
|
||||
if v.startswith("-"):
|
||||
raise ValueError(
|
||||
"INVALID_TARGET_BRANCH: target_branch must not start with '-'"
|
||||
)
|
||||
if v in ("master", "main"):
|
||||
raise ValueError(
|
||||
f"PROTECTED_BRANCH: Cannot rebase onto '{v}'; "
|
||||
"target_branch must not be 'master' or 'main'"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class GitRebaseResponse(BaseModel):
|
||||
"""Response from git rebase.
|
||||
|
||||
On success: conflict=False, conflicted_files=[].
|
||||
On conflict: conflict=True, conflicted_files lists the unmerged paths;
|
||||
the rebase has been aborted so the workspace is clean.
|
||||
"""
|
||||
|
||||
project_slug: str
|
||||
conflict: bool = False
|
||||
conflicted_files: list[str] = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GATEWAY-LAYER LIGHTWEIGHT SCHEMAS
|
||||
#
|
||||
# These simpler schemas are used by the MCP gateway layer and services that
|
||||
# don't need the full Git* request payload. All fields beyond project_slug
|
||||
# are optional to allow callers that don't yet carry task context.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PullRequest(BaseModel):
|
||||
"""Lightweight pull request used by the gateway / MCP layer."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID | None = None
|
||||
|
||||
|
||||
class FetchRequest(BaseModel):
|
||||
"""Lightweight fetch request used by the gateway / MCP layer."""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID | None = None
|
||||
|
||||
|
||||
class RebaseRequest(BaseModel):
|
||||
"""Lightweight rebase request used by the gateway / MCP layer.
|
||||
|
||||
Validates ``target_branch`` to prevent accidental rebases onto
|
||||
protected branches or shell-injection via leading ``-``.
|
||||
"""
|
||||
|
||||
project_slug: str
|
||||
task_id: UUID | None = None
|
||||
target_branch: str
|
||||
|
||||
@field_validator("target_branch")
|
||||
@classmethod
|
||||
def _validate_target_branch(cls, v: str) -> str:
|
||||
if v.startswith("-"):
|
||||
raise ValueError(
|
||||
"INVALID_TARGET_BRANCH: target_branch must not start with '-'"
|
||||
)
|
||||
if v in ("master", "main"):
|
||||
raise ValueError(
|
||||
f"PROTECTED_BRANCH: Cannot rebase onto '{v}'; "
|
||||
"target_branch must not be 'master' or 'main'"
|
||||
)
|
||||
return v
|
||||
|
||||
+268
-76
@@ -530,10 +530,13 @@ class GitService(BaseService):
|
||||
) -> tuple[str, str, int, int, int]:
|
||||
"""Create a git commit with template-based message.
|
||||
|
||||
When ``request.task_id`` is ``None`` the traceability template is
|
||||
skipped and a plain conventional-commit message is built instead
|
||||
(``type(scope): description``). The git commit still happens; the
|
||||
commit is just not linked to any task record.
|
||||
|
||||
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
|
||||
"""
|
||||
task_id = request.task_id
|
||||
|
||||
# Stage files. Large changesets get the longer commit-timeout budget
|
||||
# (see `git_commit_timeout_seconds`) — the same reason the gateway
|
||||
# `commit()` adapter uses it.
|
||||
@@ -544,30 +547,42 @@ class GitService(BaseService):
|
||||
else:
|
||||
await self._run_git(workspace, ["add", "-A"], timeout=commit_timeout)
|
||||
|
||||
# Get task info for commit template
|
||||
task_service = get_task_service(self.session)
|
||||
task = await task_service.get(task_id)
|
||||
if request.task_id is not None:
|
||||
# Get task info for commit template
|
||||
task_service = get_task_service(self.session)
|
||||
task = await task_service.get(request.task_id)
|
||||
|
||||
# Get root task ID (walk up hierarchy)
|
||||
root_task_id = await get_root_task_id(task_id, task_service)
|
||||
# Get root task ID (walk up hierarchy)
|
||||
root_task_id = await get_root_task_id(request.task_id, task_service)
|
||||
|
||||
# Get session ID
|
||||
session_id = self._get_primary_session_id(task)
|
||||
# Get session ID
|
||||
session_id = self._get_primary_session_id(task)
|
||||
|
||||
# Build commit message using template
|
||||
commit_ctx = CommitContext(
|
||||
task_id=str(task_id),
|
||||
root_task_id=str(root_task_id),
|
||||
agent_slug=str(agent_id),
|
||||
session_id=session_id,
|
||||
commit_type=request.commit_type,
|
||||
scope=request.scope,
|
||||
description=request.message,
|
||||
body=request.body,
|
||||
)
|
||||
full_message = build_commit_message(
|
||||
commit_ctx, settings.public_base_url.rstrip("/") + "/api"
|
||||
)
|
||||
# Build commit message using template
|
||||
commit_ctx = CommitContext(
|
||||
task_id=str(request.task_id),
|
||||
root_task_id=str(root_task_id),
|
||||
agent_slug=str(agent_id),
|
||||
session_id=session_id,
|
||||
commit_type=request.commit_type,
|
||||
scope=request.scope,
|
||||
description=request.message,
|
||||
body=request.body,
|
||||
)
|
||||
full_message = build_commit_message(
|
||||
commit_ctx, settings.public_base_url.rstrip("/") + "/api"
|
||||
)
|
||||
else:
|
||||
# No task context — use plain conventional-commit format so the
|
||||
# git op still proceeds without raising CommitMessageError.
|
||||
type_scope = (
|
||||
f"{request.commit_type}({request.scope})"
|
||||
if request.scope
|
||||
else request.commit_type
|
||||
)
|
||||
full_message = f"{type_scope}: {request.message}"
|
||||
if request.body:
|
||||
full_message = f"{full_message}\n\n{request.body}"
|
||||
|
||||
# Create commit with agent attribution
|
||||
author = f"{agent_id} <{agent_id}@roboco.ai>"
|
||||
@@ -680,12 +695,18 @@ class GitService(BaseService):
|
||||
commit itself, and commit-to-task linking. Raises typed service
|
||||
errors; the API layer translates them to HTTP status codes.
|
||||
|
||||
When ``data.task_id`` is ``None`` the ownership, assignment, and
|
||||
branch-mismatch checks are skipped — the git commit proceeds
|
||||
unconditionally, and no commit-to-task linking is recorded.
|
||||
|
||||
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
|
||||
"""
|
||||
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
|
||||
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
await self._assert_on_task_branch(workspace, task.branch_name)
|
||||
if data.task_id is not None:
|
||||
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
await self._assert_on_task_branch(workspace, task.branch_name)
|
||||
else:
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
|
||||
(
|
||||
commit_hash,
|
||||
@@ -695,9 +716,10 @@ class GitService(BaseService):
|
||||
deletions,
|
||||
) = await self.create_commit(workspace, agent_id, data)
|
||||
|
||||
await self._link_commit_to_task(
|
||||
data.task_id, commit_hash, data.message, agent_id
|
||||
)
|
||||
if data.task_id is not None:
|
||||
await self._link_commit_to_task(
|
||||
data.task_id, commit_hash, data.message, agent_id
|
||||
)
|
||||
|
||||
return commit_hash, full_message, files_changed, insertions, deletions
|
||||
|
||||
@@ -1146,6 +1168,10 @@ class GitService(BaseService):
|
||||
preconditions. Raises typed service errors; the API layer
|
||||
translates them to HTTP status codes.
|
||||
|
||||
When ``data.task_id`` is ``None`` the ownership and branch-mismatch
|
||||
checks are skipped — the push proceeds unconditionally (force-push
|
||||
role check still applies).
|
||||
|
||||
Returns: (branch, commits_pushed)
|
||||
"""
|
||||
if getattr(data, "force", False) and agent_role != AgentRole.CEO:
|
||||
@@ -1159,10 +1185,12 @@ class GitService(BaseService):
|
||||
),
|
||||
)
|
||||
|
||||
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
|
||||
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
await self._assert_on_task_branch(workspace, task.branch_name)
|
||||
if data.task_id is not None:
|
||||
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
await self._assert_on_task_branch(workspace, task.branch_name)
|
||||
else:
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
|
||||
return await self.push(workspace, getattr(data, "force", False))
|
||||
|
||||
@@ -1186,6 +1214,135 @@ class GitService(BaseService):
|
||||
_branch, pushed = await self.push(workspace)
|
||||
return pushed
|
||||
|
||||
# =========================================================================
|
||||
# PULL / FETCH / REBASE METHODS
|
||||
# =========================================================================
|
||||
|
||||
async def pull(
|
||||
self, workspace: Path
|
||||
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
|
||||
"""Pull latest changes from origin and return post-pull status.
|
||||
|
||||
Safety gates (both raise :class:`ValidationError`):
|
||||
|
||||
1. **Dirty workspace** — refuses to pull when there are any
|
||||
uncommitted changes (staged, unstaged, or untracked). A pull
|
||||
onto a dirty tree can produce unexpected merge conflicts or
|
||||
silently clobber un-staged edits.
|
||||
|
||||
2. **Diverged branch** — uses ``--ff-only`` so a diverged branch
|
||||
is rejected rather than creating a merge commit. The agent
|
||||
must rebase or reset before pulling.
|
||||
|
||||
Uses _network_git_timeout() because the operation talks to origin.
|
||||
|
||||
Returns: (current_branch, has_changes, staged, unstaged, untracked,
|
||||
ahead, behind)
|
||||
"""
|
||||
# Gate 1: dirty workspace check
|
||||
status_result = await self._run_git(
|
||||
workspace, ["status", "--porcelain"], check=False
|
||||
)
|
||||
if status_result.stdout.strip():
|
||||
raise ValidationError(
|
||||
"DIRTY_WORKSPACE: Cannot pull with uncommitted changes. "
|
||||
"Stage and commit (or stash) your changes before pulling."
|
||||
)
|
||||
|
||||
token = await self._token_for_workspace(workspace)
|
||||
# Gate 2: fast-forward only — raises if branches have diverged
|
||||
pull_result = await self._run_git(
|
||||
workspace,
|
||||
["pull", "--ff-only"],
|
||||
token=token,
|
||||
timeout=_network_git_timeout(),
|
||||
check=False,
|
||||
)
|
||||
if pull_result.returncode != 0:
|
||||
stderr = (pull_result.stderr or "").lower()
|
||||
if any(
|
||||
kw in stderr
|
||||
for kw in (
|
||||
"not possible to fast-forward",
|
||||
"diverged",
|
||||
"fatal: not possible to fast",
|
||||
"fast-forward",
|
||||
)
|
||||
):
|
||||
raise ValidationError(
|
||||
"DIVERGED_BRANCH: Branch has diverged from remote; "
|
||||
"cannot fast-forward. Rebase your local commits onto "
|
||||
"the remote tip with `git rebase origin/<branch>` "
|
||||
"before pulling."
|
||||
)
|
||||
raise ValidationError(
|
||||
f"PULL_FAILED: git pull --ff-only exited non-zero. "
|
||||
f"stderr: {pull_result.stderr or '(none)'}"
|
||||
)
|
||||
return await self.get_status(workspace)
|
||||
|
||||
async def fetch(
|
||||
self, workspace: Path
|
||||
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
|
||||
"""Fetch changes from origin without merging and return post-fetch status.
|
||||
|
||||
Uses _network_git_timeout() because the operation talks to origin.
|
||||
|
||||
Returns: (current_branch, has_changes, staged, unstaged, untracked,
|
||||
ahead, behind)
|
||||
"""
|
||||
token = await self._token_for_workspace(workspace)
|
||||
await self._run_git(
|
||||
workspace,
|
||||
["fetch", "origin"],
|
||||
token=token,
|
||||
timeout=_network_git_timeout(),
|
||||
)
|
||||
return await self.get_status(workspace)
|
||||
|
||||
async def rebase(
|
||||
self, workspace: Path, target_branch: str
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Rebase the current branch onto target_branch.
|
||||
|
||||
Safety gate: raises :class:`ValidationError` if the HEAD branch or
|
||||
``target_branch`` is ``master`` or ``main`` — rebasing a protected
|
||||
integration branch is never safe in automation.
|
||||
|
||||
On conflict (non-zero exit): captures unmerged files via
|
||||
``git diff --name-only --diff-filter=U``, aborts the rebase to
|
||||
restore a clean workspace, and returns ``(True, conflicted_files)``.
|
||||
|
||||
On success: returns ``(False, [])``.
|
||||
"""
|
||||
_PROTECTED = frozenset({"master", "main"})
|
||||
if target_branch in _PROTECTED:
|
||||
raise ValidationError(
|
||||
f"REBASE_FORBIDDEN: Cannot rebase onto '{target_branch}'. "
|
||||
"Rebasing onto 'master' or 'main' is not allowed in automation."
|
||||
)
|
||||
head_branch = await self.get_current_branch(workspace)
|
||||
if head_branch in _PROTECTED:
|
||||
raise ValidationError(
|
||||
f"REBASE_FORBIDDEN: Cannot rebase '{head_branch}'. "
|
||||
"Rebasing 'master' or 'main' is not allowed in automation."
|
||||
)
|
||||
result = await self._run_git(workspace, ["rebase", target_branch], check=False)
|
||||
if result.returncode != 0:
|
||||
conflict_result = await self._run_git(
|
||||
workspace,
|
||||
["diff", "--name-only", "--diff-filter=U"],
|
||||
check=False,
|
||||
)
|
||||
conflicted_files = [
|
||||
f.strip()
|
||||
for f in conflict_result.stdout.strip().split("\n")
|
||||
if f.strip()
|
||||
]
|
||||
await self._run_git(workspace, ["rebase", "--abort"], check=False)
|
||||
return True, conflicted_files
|
||||
return False, []
|
||||
|
||||
# =========================================================================
|
||||
# PR METHODS
|
||||
# =========================================================================
|
||||
@@ -1542,26 +1699,37 @@ class GitService(BaseService):
|
||||
) -> tuple[int, str, str, str, str]:
|
||||
"""Create a pull request via the GitHub REST API.
|
||||
|
||||
When ``request.task_id`` is ``None`` the task lookup, target-branch
|
||||
resolution, and template generation are skipped. The PR targets the
|
||||
project's default branch and uses ``request.title`` / ``request.body``
|
||||
directly (falling back to ``source_branch`` / ``""`` when absent).
|
||||
|
||||
Returns: (pr_number, pr_url, title, source_branch, target_branch)
|
||||
"""
|
||||
task_service = get_task_service(self.session)
|
||||
task = await task_service.get(request.task_id)
|
||||
if not task:
|
||||
raise NotFoundError("Task", str(request.task_id))
|
||||
|
||||
source_branch = await self.get_current_branch(workspace)
|
||||
default_branch = await self._project_default_branch(request.project_slug)
|
||||
git_token = await self._get_project_token_or_raise(request.project_slug)
|
||||
|
||||
target_branch = await self._resolve_pr_target_branch(
|
||||
request, task, default_branch
|
||||
)
|
||||
target_branch = await self._pr_base_on_remote(
|
||||
workspace, target_branch, default_branch, git_token, request.task_id
|
||||
)
|
||||
pr_title, pr_body = await self._generate_pr_title_body(
|
||||
request, task, source_branch, target_branch, request.task_id
|
||||
)
|
||||
if request.task_id is not None:
|
||||
task_service = get_task_service(self.session)
|
||||
task = await task_service.get(request.task_id)
|
||||
if not task:
|
||||
raise NotFoundError("Task", str(request.task_id))
|
||||
target_branch = await self._resolve_pr_target_branch(
|
||||
request, task, default_branch
|
||||
)
|
||||
target_branch = await self._pr_base_on_remote(
|
||||
workspace, target_branch, default_branch, git_token, request.task_id
|
||||
)
|
||||
pr_title, pr_body = await self._generate_pr_title_body(
|
||||
request, task, source_branch, target_branch, request.task_id
|
||||
)
|
||||
else:
|
||||
# No task context: target the default branch directly, use provided
|
||||
# title/body or fall back to minimal defaults.
|
||||
target_branch = default_branch
|
||||
pr_title = request.title or source_branch
|
||||
pr_body = request.body or ""
|
||||
|
||||
owner, repo = self._parse_github_remote(workspace)
|
||||
resp = await self._post_pr(
|
||||
@@ -1932,9 +2100,14 @@ class GitService(BaseService):
|
||||
) -> tuple[int, str, str, str, str]:
|
||||
"""Preconditions + PR creation + state sync.
|
||||
|
||||
When ``data.task_id`` is ``None`` the ownership/state gate and the
|
||||
post-creation task-state sync are both skipped — the GitHub PR is still
|
||||
created, but no task record is updated.
|
||||
|
||||
Returns: (pr_number, pr_url, title, source_branch, target_branch)
|
||||
"""
|
||||
await self._assert_pr_create_allowed(data.task_id, agent_id)
|
||||
if data.task_id is not None:
|
||||
await self._assert_pr_create_allowed(data.task_id, agent_id)
|
||||
|
||||
workspace = await self.get_workspace(data.project_slug, agent_id)
|
||||
(
|
||||
@@ -1945,7 +2118,8 @@ class GitService(BaseService):
|
||||
target_branch,
|
||||
) = await self.create_pull_request(workspace, data)
|
||||
|
||||
await self._record_pr_atomically(data.task_id, pr_number, pr_url)
|
||||
if data.task_id is not None:
|
||||
await self._record_pr_atomically(data.task_id, pr_number, pr_url)
|
||||
return pr_number, pr_url, title, source_branch, target_branch
|
||||
|
||||
async def _call_merge_api(
|
||||
@@ -2215,40 +2389,58 @@ class GitService(BaseService):
|
||||
) -> tuple[str, str]:
|
||||
"""Role-gated merge + work-session record + auto-complete.
|
||||
|
||||
When ``data.task_id`` is ``None`` the task lookup, role gate,
|
||||
work-session update, and auto-complete are all skipped — the GitHub PR
|
||||
merge still happens using the caller-provided ``project_slug`` and
|
||||
``pr_number``.
|
||||
|
||||
Returns: (target_branch, merge_commit)
|
||||
"""
|
||||
task_service = get_task_service(self.session)
|
||||
work_session_service = get_work_session_service(self.session)
|
||||
if data.task_id is not None:
|
||||
task_service = get_task_service(self.session)
|
||||
work_session_service = get_work_session_service(self.session)
|
||||
|
||||
task = await task_service.get(data.task_id)
|
||||
if not task:
|
||||
raise NotFoundError(resource_type="Task", resource_id=str(data.task_id))
|
||||
self._assert_merge_role(self._status_value(task), agent_role)
|
||||
task = await task_service.get(data.task_id)
|
||||
if not task:
|
||||
raise NotFoundError(resource_type="Task", resource_id=str(data.task_id))
|
||||
self._assert_merge_role(self._status_value(task), agent_role)
|
||||
|
||||
# A coordination root has no project of its own, so the CEO's merge
|
||||
# request can't carry a project_slug. Resolve the root's repo from its
|
||||
# product server-side; non-root tasks keep the client-provided slug.
|
||||
project_slug = data.project_slug
|
||||
if task.project_id is None:
|
||||
root_project = await self._project_for_task(task)
|
||||
if root_project is not None:
|
||||
project_slug = root_project.slug
|
||||
# A coordination root has no project of its own, so the CEO's merge
|
||||
# request can't carry a project_slug. Resolve the root's repo from
|
||||
# its product server-side; non-root tasks keep the client slug.
|
||||
project_slug = data.project_slug
|
||||
if task.project_id is None:
|
||||
root_project = await self._project_for_task(task)
|
||||
if root_project is not None:
|
||||
project_slug = root_project.slug
|
||||
|
||||
workspace = await self.get_workspace(project_slug, agent_id)
|
||||
target_branch, merge_commit = await self.merge_pull_request(
|
||||
workspace=workspace,
|
||||
pr_number=data.pr_number,
|
||||
merge_method=data.merge_method,
|
||||
project_slug=project_slug,
|
||||
)
|
||||
workspace = await self.get_workspace(project_slug, agent_id)
|
||||
target_branch, merge_commit = await self.merge_pull_request(
|
||||
workspace=workspace,
|
||||
pr_number=data.pr_number,
|
||||
merge_method=data.merge_method,
|
||||
project_slug=project_slug,
|
||||
)
|
||||
|
||||
if task.work_session_id:
|
||||
await work_session_service.merge_pr(
|
||||
require_uuid(task.work_session_id), agent_id
|
||||
if task.work_session_id:
|
||||
await work_session_service.merge_pr(
|
||||
require_uuid(task.work_session_id), agent_id
|
||||
)
|
||||
|
||||
await self._auto_complete_on_merge(data.task_id, agent_id, agent_role)
|
||||
await self.session.commit()
|
||||
else:
|
||||
# No task context — proceed directly to the merge without
|
||||
# role/ownership checks or post-merge state transitions.
|
||||
project_slug = data.project_slug
|
||||
workspace = await self.get_workspace(project_slug, agent_id)
|
||||
target_branch, merge_commit = await self.merge_pull_request(
|
||||
workspace=workspace,
|
||||
pr_number=data.pr_number,
|
||||
merge_method=data.merge_method,
|
||||
project_slug=project_slug,
|
||||
)
|
||||
|
||||
await self._auto_complete_on_merge(data.task_id, agent_id, agent_role)
|
||||
await self.session.commit()
|
||||
return target_branch, merge_commit
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -1297,7 +1297,6 @@ class TaskService(BaseService):
|
||||
task_id=require_uuid(task.id),
|
||||
project_slug=project.slug,
|
||||
branch_type="feature",
|
||||
agent_id=str(agent_id),
|
||||
parent_branch=parent_branch,
|
||||
)
|
||||
|
||||
|
||||
@@ -85,6 +85,59 @@ async def git_client(
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def pm_git_client(
|
||||
db_session: AsyncSession,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Like git_client but with CELL_PM role — required for the rebase endpoint."""
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="PM",
|
||||
slug=f"be-pm-{uuid4().hex[:8]}",
|
||||
role=AgentRole.CELL_PM,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="pm",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="GitProj",
|
||||
slug=f"git-proj-{uuid4().hex[:6]}",
|
||||
git_url="https://example.com/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=agent.id,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.flush()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(git_router, prefix="/api/git")
|
||||
|
||||
async def _override_db() -> AsyncGenerator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
async def _override_agent() -> AgentContext:
|
||||
return AgentContext(
|
||||
agent_id=cast("uuid.UUID", agent.id),
|
||||
role=AgentRole.CELL_PM,
|
||||
team=Team.BACKEND,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
app.dependency_overrides[get_agent_context] = _override_agent
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield {"client": client, "agent": agent, "project": project, "db": db_session}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"}
|
||||
|
||||
|
||||
@@ -681,5 +734,267 @@ async def test_status_generic_service_error(git_client: dict) -> None:
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pull
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_success(git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
svc.pull = AsyncMock(return_value=("main", False, [], [], [], 0, 0))
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/pull",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
"task_id": str(uuid4()),
|
||||
"agent_id": str(uuid4()),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()
|
||||
assert data["current_branch"] == "main"
|
||||
assert "ahead" in data
|
||||
assert "behind" in data
|
||||
assert "has_changes" in data
|
||||
assert "staged_files" in data
|
||||
assert "unstaged_files" in data
|
||||
assert "untracked_files" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_git_command_error(git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
svc.pull = AsyncMock(side_effect=GitCommandError("pull", "network error"))
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/pull",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
"task_id": str(uuid4()),
|
||||
"agent_id": str(uuid4()),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_success(git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
_ahead = 2
|
||||
_behind = 1
|
||||
svc.fetch = AsyncMock(
|
||||
return_value=("feature/x", True, ["a.py"], [], [], _ahead, _behind)
|
||||
)
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/fetch",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
"task_id": str(uuid4()),
|
||||
"agent_id": str(uuid4()),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()
|
||||
assert data["current_branch"] == "feature/x"
|
||||
assert data["ahead"] == _ahead
|
||||
assert data["behind"] == _behind
|
||||
assert data["has_changes"] is True
|
||||
assert "staged_files" in data
|
||||
assert "unstaged_files" in data
|
||||
assert "untracked_files" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_git_command_error(git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
svc.fetch = AsyncMock(side_effect=GitCommandError("fetch", "network error"))
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/fetch",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
"task_id": str(uuid4()),
|
||||
"agent_id": str(uuid4()),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rebase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_success(pm_git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
svc.rebase = AsyncMock(return_value=(False, []))
|
||||
mock_get.return_value = svc
|
||||
response = await pm_git_client["client"].post(
|
||||
"/api/git/rebase",
|
||||
json={
|
||||
"project_slug": pm_git_client["project"].slug,
|
||||
"target_branch": "develop",
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()
|
||||
assert data["conflict"] is False
|
||||
assert data["conflicted_files"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_conflict(pm_git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
svc.rebase = AsyncMock(return_value=(True, ["src/foo.py", "src/bar.py"]))
|
||||
mock_get.return_value = svc
|
||||
response = await pm_git_client["client"].post(
|
||||
"/api/git/rebase",
|
||||
json={
|
||||
"project_slug": pm_git_client["project"].slug,
|
||||
"target_branch": "develop",
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()
|
||||
assert data["conflict"] is True
|
||||
assert data["conflicted_files"] == ["src/foo.py", "src/bar.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_git_command_error(pm_git_client: dict) -> None:
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||
svc.rebase = AsyncMock(side_effect=GitCommandError("rebase", "fatal error"))
|
||||
mock_get.return_value = svc
|
||||
response = await pm_git_client["client"].post(
|
||||
"/api/git/rebase",
|
||||
json={
|
||||
"project_slug": pm_git_client["project"].slug,
|
||||
"target_branch": "develop",
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_id Optional — no 422 when task_id is omitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_without_task_id_no_422(git_client: dict) -> None:
|
||||
"""POST /commit without task_id must not return 422 (schema validation error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.commit_for_task = AsyncMock(
|
||||
return_value=("abc123", "feat: add thing", 1, 5, 2)
|
||||
)
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/commit",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
"agent_id": str(uuid4()),
|
||||
"message": "add a new thing",
|
||||
"commit_type": "feat",
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_without_task_id_no_422(git_client: dict) -> None:
|
||||
"""POST /push without task_id must not return 422 (schema validation error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.push_for_task = AsyncMock(return_value=("feature/x", 3))
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/push",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_without_task_id_no_422(git_client: dict) -> None:
|
||||
"""POST /pr/create without task_id must not return 422 (schema validation error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.create_pr_for_task = AsyncMock(
|
||||
return_value=(
|
||||
7,
|
||||
"https://github.com/x/y/pull/7",
|
||||
"feat: add thing",
|
||||
"feat/x",
|
||||
"main",
|
||||
)
|
||||
)
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/pr/create",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None:
|
||||
"""POST /pr/merge without task_id must not return 422 (schema validation error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||
svc = AsyncMock()
|
||||
svc.merge_pr_for_task = AsyncMock(return_value=("main", "deadbeef"))
|
||||
mock_get.return_value = svc
|
||||
response = await git_client["client"].post(
|
||||
"/api/git/pr/merge",
|
||||
json={
|
||||
"project_slug": git_client["project"].slug,
|
||||
"pr_number": 99,
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
# Re-export to keep import alive (TC reorders imports)
|
||||
_ = SimpleNamespace
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Unit tests: task_id is Optional in git request schemas and service methods.
|
||||
|
||||
These tests verify:
|
||||
- The four git schemas accept None task_id (no 422 on absent field).
|
||||
- Existing callers that pass task_id still work (regression).
|
||||
- The HTTP endpoints return 200 when task_id is omitted.
|
||||
|
||||
All tests run without a real database or git process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.git import router as git_router
|
||||
from roboco.api.schemas.git import (
|
||||
GitCommitRequest,
|
||||
GitCreatePRRequest,
|
||||
GitMergePRRequest,
|
||||
GitPushRequest,
|
||||
)
|
||||
from roboco.models.base import AgentRole, Team
|
||||
from roboco.models.permissions import AgentContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
|
||||
_HTTP_UNPROCESSABLE = 422
|
||||
_HTTP_OK = 200
|
||||
|
||||
_AGENT_ID = uuid4()
|
||||
_TASK_ID = uuid4()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures — no real DB required
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncIterator[AsyncClient]:
|
||||
"""FastAPI test client with mocked auth + DB; no Postgres needed."""
|
||||
app = FastAPI()
|
||||
app.include_router(git_router, prefix="/api/git")
|
||||
|
||||
async def _mock_db() -> AsyncGenerator:
|
||||
yield AsyncMock() # DB session is never touched in these tests
|
||||
|
||||
async def _mock_agent() -> AgentContext:
|
||||
return AgentContext(
|
||||
agent_id=_AGENT_ID,
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = _mock_db
|
||||
app.dependency_overrides[get_agent_context] = _mock_agent
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
_HDR = {"X-Agent-ID": str(_AGENT_ID), "X-Agent-Role": "developer"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema unit tests — direct Pydantic validation, no HTTP needed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_commit_request_task_id_optional() -> None:
|
||||
"""GitCommitRequest accepts a missing task_id (defaults to None)."""
|
||||
req = GitCommitRequest(
|
||||
project_slug="roboco",
|
||||
message="add something new here",
|
||||
commit_type="feat",
|
||||
)
|
||||
assert req.task_id is None
|
||||
|
||||
|
||||
def test_commit_request_task_id_present() -> None:
|
||||
"""GitCommitRequest still accepts an explicit task_id (regression)."""
|
||||
req = GitCommitRequest(
|
||||
project_slug="roboco",
|
||||
task_id=_TASK_ID,
|
||||
message="add something new here",
|
||||
commit_type="feat",
|
||||
)
|
||||
assert req.task_id == _TASK_ID
|
||||
|
||||
|
||||
def test_push_request_task_id_optional() -> None:
|
||||
"""GitPushRequest accepts a missing task_id (defaults to None)."""
|
||||
req = GitPushRequest(project_slug="roboco")
|
||||
assert req.task_id is None
|
||||
|
||||
|
||||
def test_push_request_task_id_present() -> None:
|
||||
"""GitPushRequest still accepts an explicit task_id (regression)."""
|
||||
req = GitPushRequest(project_slug="roboco", task_id=_TASK_ID)
|
||||
assert req.task_id == _TASK_ID
|
||||
|
||||
|
||||
def test_create_pr_request_task_id_optional() -> None:
|
||||
"""GitCreatePRRequest accepts a missing task_id (defaults to None)."""
|
||||
req = GitCreatePRRequest(project_slug="roboco")
|
||||
assert req.task_id is None
|
||||
|
||||
|
||||
def test_create_pr_request_task_id_present() -> None:
|
||||
"""GitCreatePRRequest still accepts an explicit task_id (regression)."""
|
||||
req = GitCreatePRRequest(project_slug="roboco", task_id=_TASK_ID)
|
||||
assert req.task_id == _TASK_ID
|
||||
|
||||
|
||||
def test_merge_pr_request_task_id_optional() -> None:
|
||||
"""GitMergePRRequest accepts a missing task_id (defaults to None)."""
|
||||
req = GitMergePRRequest(project_slug="roboco", pr_number=42)
|
||||
assert req.task_id is None
|
||||
|
||||
|
||||
def test_merge_pr_request_task_id_present() -> None:
|
||||
"""GitMergePRRequest still accepts an explicit task_id (regression)."""
|
||||
req = GitMergePRRequest(project_slug="roboco", pr_number=42, task_id=_TASK_ID)
|
||||
assert req.task_id == _TASK_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP endpoint tests — verify no 422 when task_id is absent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_without_task_id_returns_200_not_422(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""POST /commit without task_id must not return 422 (schema error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.commit_for_task = AsyncMock(
|
||||
return_value=("deadbeef", "feat: add something", 1, 5, 2)
|
||||
)
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/commit",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"agent_id": str(_AGENT_ID),
|
||||
"message": "add something new",
|
||||
"commit_type": "feat",
|
||||
# task_id intentionally omitted
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != _HTTP_UNPROCESSABLE, (
|
||||
f"Expected no 422, got {response.status_code}: {response.text}"
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_without_task_id_returns_200_not_422(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""POST /push without task_id must not return 422 (schema error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.push_for_task = AsyncMock(return_value=("feature/x", 3))
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/push",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
# task_id intentionally omitted
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != _HTTP_UNPROCESSABLE, (
|
||||
f"Expected no 422, got {response.status_code}: {response.text}"
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_without_task_id_returns_200_not_422(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""POST /pr/create without task_id must not return 422 (schema error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.create_pr_for_task = AsyncMock(
|
||||
return_value=(
|
||||
7,
|
||||
"https://github.com/x/y/pull/7",
|
||||
"feat: add thing",
|
||||
"feature/x",
|
||||
"main",
|
||||
)
|
||||
)
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/pr/create",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
# task_id intentionally omitted
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != _HTTP_UNPROCESSABLE, (
|
||||
f"Expected no 422, got {response.status_code}: {response.text}"
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_without_task_id_returns_200_not_422(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""POST /pr/merge without task_id must not return 422 (schema error)."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.merge_pr_for_task = AsyncMock(return_value=("main", "deadbeef"))
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/pr/merge",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"pr_number": 99,
|
||||
# task_id intentionally omitted
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code != _HTTP_UNPROCESSABLE, (
|
||||
f"Expected no 422, got {response.status_code}: {response.text}"
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: existing callers that pass task_id still get 200
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_with_task_id_still_works(client: AsyncClient) -> None:
|
||||
"""POST /commit with task_id (regression) must still return 200."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.commit_for_task = AsyncMock(
|
||||
return_value=("abc123", "fix(auth): correct thing", 2, 10, 3)
|
||||
)
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/commit",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"task_id": str(_TASK_ID),
|
||||
"agent_id": str(_AGENT_ID),
|
||||
"message": "correct the auth flow",
|
||||
"commit_type": "fix",
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_with_task_id_still_works(client: AsyncClient) -> None:
|
||||
"""POST /push with task_id (regression) must still return 200."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.push_for_task = AsyncMock(return_value=("feature/x", 2))
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/push",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"task_id": str(_TASK_ID),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_with_task_id_still_works(client: AsyncClient) -> None:
|
||||
"""POST /pr/create with task_id (regression) must still return 200."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.create_pr_for_task = AsyncMock(
|
||||
return_value=(5, "https://github.com/x/y/pull/5", "T", "feat", "main")
|
||||
)
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/pr/create",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"task_id": str(_TASK_ID),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_with_task_id_still_works(client: AsyncClient) -> None:
|
||||
"""POST /pr/merge with task_id (regression) must still return 200."""
|
||||
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
|
||||
svc = AsyncMock()
|
||||
svc.merge_pr_for_task = AsyncMock(return_value=("main", "cafebabe"))
|
||||
mock_svc.return_value = svc
|
||||
response = await client.post(
|
||||
"/api/git/pr/merge",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"pr_number": 5,
|
||||
"task_id": str(_TASK_ID),
|
||||
},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == _HTTP_OK
|
||||
@@ -63,8 +63,7 @@ def test_get_agent_image_registry_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(orch.settings, "agent_image_registry", "ghcr.io/rennf93")
|
||||
monkeypatch.setattr(orch.settings, "agent_image_tag", "0.5.0")
|
||||
assert (
|
||||
orch.get_agent_image("be-dev-1")
|
||||
== "ghcr.io/rennf93/roboco-agent-dev-be:0.5.0"
|
||||
orch.get_agent_image("be-dev-1") == "ghcr.io/rennf93/roboco-agent-dev-be:0.5.0"
|
||||
)
|
||||
assert (
|
||||
orch.get_agent_image("pr-reviewer-1")
|
||||
|
||||
@@ -550,7 +550,6 @@ async def test_create_branch_idempotent_when_branch_already_exists() -> None:
|
||||
project_slug="roboco-api",
|
||||
task_id=uuid4(),
|
||||
branch_type="feature",
|
||||
agent_id=str(uuid4()),
|
||||
parent_branch=None,
|
||||
),
|
||||
)
|
||||
@@ -601,7 +600,6 @@ async def _run_create_branch_with_existing_branch(
|
||||
project_slug="roboco-panel",
|
||||
task_id=uuid4(),
|
||||
branch_type="feature",
|
||||
agent_id=str(uuid4()),
|
||||
parent_branch=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Unit tests for GitService rebase conflict-state handling.
|
||||
|
||||
Pins the three critical control-flow branches of ``rebase_onto_base``:
|
||||
|
||||
1. **Success** — the underlying ``git rebase`` exits 0 → method returns a
|
||||
non-conflict result dict and never calls ``git rebase --abort``.
|
||||
2. **Conflict** — ``git rebase`` exits non-zero → method calls
|
||||
``git diff --name-only --diff-filter=U`` to collect conflicted files,
|
||||
calls ``git rebase --abort`` to restore the workspace, and returns a
|
||||
conflict result dict.
|
||||
3. **Resilience** — both ``git rebase`` and ``git rebase --abort`` exit
|
||||
non-zero (e.g. abort fails mid-stream). The method must still return
|
||||
the conflict dict without propagating an exception, because both are
|
||||
invoked with ``check=False``.
|
||||
|
||||
All tests mock ``_run_git`` at the service-method level using
|
||||
``AsyncMock`` with a ``side_effect`` list so each awaited call consumes
|
||||
the next pre-configured result in order.
|
||||
|
||||
Also covers the ``rebase()`` safety gate added by the git-schema cleanup
|
||||
task: rebasing onto or from a protected branch (master/main) is rejected
|
||||
with a service-layer ``ValidationError`` before any git command runs.
|
||||
|
||||
Also covers:
|
||||
* ``pull()`` dirty-tree and diverged-branch ``ValidationError`` gates.
|
||||
* ``pull()`` success path.
|
||||
* ``GitRebaseRequest.target_branch`` Pydantic field validator.
|
||||
* Route-level role gate: DEVELOPER → 403, CELL_PM → 200.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pydantic
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.git import router as git_router
|
||||
from roboco.api.schemas.git import GitRebaseRequest
|
||||
from roboco.models.base import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.base import ValidationError
|
||||
from roboco.services.git import GitService
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_403 = 403
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HEAD = "feature/backend/root--task"
|
||||
_BASE = "feature/backend/root"
|
||||
_WORKSPACE = Path("/tmp/fake-ws")
|
||||
_TOKEN = "ghp_fake"
|
||||
|
||||
|
||||
def _git_service() -> GitService:
|
||||
"""Instantiate GitService without a real DB session."""
|
||||
svc = GitService.__new__(GitService)
|
||||
svc.log = MagicMock() # silence warning/info calls
|
||||
return svc
|
||||
|
||||
|
||||
def _result(returncode: int = 0, stdout: str = "", stderr: str = "") -> Any:
|
||||
"""Minimal subprocess result stand-in."""
|
||||
r = MagicMock()
|
||||
r.returncode = returncode
|
||||
r.stdout = stdout
|
||||
r.stderr = stderr
|
||||
return r
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — success path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_path_returns_rebased_and_does_not_call_abort(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When git rebase exits 0 the method returns a non-conflict result and
|
||||
never invokes ``git rebase --abort``.
|
||||
|
||||
Call sequence for the success path (rebase OK, 2 unique commits):
|
||||
[0] fetch origin
|
||||
[1] checkout HEAD branch
|
||||
[2] reset --hard origin/HEAD
|
||||
[3] rebase origin/BASE ← exits 0
|
||||
[4] rev-list --count ← returns "2"
|
||||
[5] push --force-with-lease ← pushes the rebased branch
|
||||
"""
|
||||
run = AsyncMock(
|
||||
side_effect=[
|
||||
_result(), # [0] fetch
|
||||
_result(), # [1] checkout
|
||||
_result(), # [2] reset
|
||||
_result(), # [3] rebase ← success
|
||||
_result(stdout="2\n"), # [4] rev-list
|
||||
_result(), # [5] push
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(GitService, "_run_git", run)
|
||||
|
||||
svc = _git_service()
|
||||
result = await svc.rebase_onto_base(
|
||||
_WORKSPACE,
|
||||
head_branch=_HEAD,
|
||||
base_branch=_BASE,
|
||||
git_token=_TOKEN,
|
||||
)
|
||||
|
||||
assert result == {"status": "rebased", "unique_commits": 2}
|
||||
|
||||
# Verify abort was never called
|
||||
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
|
||||
assert abort_call not in run.call_args_list, (
|
||||
"git rebase --abort must NOT be called on a clean rebase"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — conflict path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_path_calls_diff_then_abort_and_returns_conflict_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When git rebase exits non-zero the method:
|
||||
|
||||
* calls ``git diff --name-only --diff-filter=U`` to identify conflicted files,
|
||||
* calls ``git rebase --abort`` to restore the workspace,
|
||||
* returns ``{"status": "conflicts", "files": [<conflicted files>]}``.
|
||||
|
||||
Call sequence:
|
||||
[0] fetch origin
|
||||
[1] checkout HEAD branch
|
||||
[2] reset --hard origin/HEAD
|
||||
[3] rebase origin/BASE ← exits 1 (conflict)
|
||||
[4] diff --name-only ← lists conflicted files
|
||||
[5] rebase --abort ← exits 0
|
||||
"""
|
||||
run = AsyncMock(
|
||||
side_effect=[
|
||||
_result(), # [0] fetch
|
||||
_result(), # [1] checkout
|
||||
_result(), # [2] reset
|
||||
_result(returncode=1), # [3] rebase ← conflict
|
||||
_result(stdout="src/a.py\nsrc/b.py\n"), # [4] diff
|
||||
_result(), # [5] rebase --abort
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(GitService, "_run_git", run)
|
||||
|
||||
svc = _git_service()
|
||||
result = await svc.rebase_onto_base(
|
||||
_WORKSPACE,
|
||||
head_branch=_HEAD,
|
||||
base_branch=_BASE,
|
||||
git_token=_TOKEN,
|
||||
)
|
||||
|
||||
assert result == {"status": "conflicts", "files": ["src/a.py", "src/b.py"]}
|
||||
|
||||
# Verify the diff call was made with the correct flags
|
||||
diff_call = call(
|
||||
_WORKSPACE,
|
||||
["diff", "--name-only", "--diff-filter=U"],
|
||||
check=False,
|
||||
)
|
||||
assert diff_call in run.call_args_list, (
|
||||
"git diff --name-only --diff-filter=U must be called to collect conflicted"
|
||||
" files"
|
||||
)
|
||||
|
||||
# Verify abort was called
|
||||
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
|
||||
assert abort_call in run.call_args_list, (
|
||||
"git rebase --abort must be called to restore the workspace after a conflict"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — resilience: both rebase and abort exit non-zero
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resilience_when_both_rebase_and_abort_fail_returns_conflict_no_exception(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When ``git rebase`` exits non-zero AND ``git rebase --abort`` also
|
||||
exits non-zero, the method must still return a conflict result dict
|
||||
without raising an exception.
|
||||
|
||||
Both are called with ``check=False`` so a non-zero exit code from
|
||||
either command produces a result object (not a raised exception).
|
||||
|
||||
Call sequence:
|
||||
[0] fetch origin
|
||||
[1] checkout HEAD branch
|
||||
[2] reset --hard origin/HEAD
|
||||
[3] rebase origin/BASE ← exits 1 (conflict)
|
||||
[4] diff --name-only ← lists conflicted files
|
||||
[5] rebase --abort ← exits 1 (abort also fails)
|
||||
"""
|
||||
run = AsyncMock(
|
||||
side_effect=[
|
||||
_result(), # [0] fetch
|
||||
_result(), # [1] checkout
|
||||
_result(), # [2] reset
|
||||
_result(returncode=1), # [3] rebase ← conflict
|
||||
_result(stdout="src/conflict.py\n"), # [4] diff
|
||||
_result(returncode=1), # [5] rebase --abort ← also fails
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(GitService, "_run_git", run)
|
||||
|
||||
svc = _git_service()
|
||||
|
||||
# Must not raise even though both rebase and abort return non-zero
|
||||
result = await svc.rebase_onto_base(
|
||||
_WORKSPACE,
|
||||
head_branch=_HEAD,
|
||||
base_branch=_BASE,
|
||||
git_token=_TOKEN,
|
||||
)
|
||||
|
||||
assert result == {"status": "conflicts", "files": ["src/conflict.py"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safety gate tests for rebase() — protected-branch guard
|
||||
# ---------------------------------------------------------------------------
|
||||
# These test the service-layer ``rebase()`` method (the workspace-scoped API
|
||||
# endpoint helper), NOT ``rebase_onto_base()`` (the internal gateway helper).
|
||||
# The guard runs BEFORE any git command, so no ``_run_git`` mock is needed
|
||||
# for target-branch cases; the head-branch case requires a stubbed
|
||||
# ``get_current_branch``.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_raises_validation_error_when_target_is_master() -> None:
|
||||
"""rebase() must raise ValidationError for target_branch='master'."""
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
||||
await svc.rebase(_WORKSPACE, "master")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_raises_validation_error_when_target_is_main() -> None:
|
||||
"""rebase() must raise ValidationError for target_branch='main'."""
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
||||
await svc.rebase(_WORKSPACE, "main")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_raises_validation_error_when_head_branch_is_master(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""rebase() must raise ValidationError when HEAD is 'master'.
|
||||
|
||||
The target-branch check passes (we pass a safe target), but the
|
||||
head-branch guard fires when get_current_branch returns 'master'.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
GitService,
|
||||
"get_current_branch",
|
||||
AsyncMock(return_value="master"),
|
||||
)
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
||||
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_raises_validation_error_when_head_branch_is_main(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""rebase() must raise ValidationError when HEAD is 'main'."""
|
||||
monkeypatch.setattr(
|
||||
GitService,
|
||||
"get_current_branch",
|
||||
AsyncMock(return_value="main"),
|
||||
)
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
||||
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pull() safety-gate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_raises_validation_error_on_dirty_tree(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""pull() raises ValidationError(DIRTY_WORKSPACE) when the tree is dirty.
|
||||
|
||||
The pre-flight ``git status --porcelain`` returns modified files, so pull
|
||||
must reject immediately before any network call.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
GitService,
|
||||
"_run_git",
|
||||
AsyncMock(return_value=_result(stdout=" M dirty.py\n")),
|
||||
)
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValidationError, match="DIRTY_WORKSPACE"):
|
||||
await svc.pull(_WORKSPACE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_raises_validation_error_on_diverged_branch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""pull() raises ValidationError(DIVERGED_BRANCH) when --ff-only fails.
|
||||
|
||||
The pre-flight status is clean, but ``git pull --ff-only`` exits non-zero
|
||||
with a "not possible to fast-forward" message because the branch has
|
||||
diverged from origin.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
GitService,
|
||||
"_run_git",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
_result(stdout=""), # status --porcelain → clean
|
||||
_result( # pull --ff-only → diverged
|
||||
returncode=1,
|
||||
stderr="fatal: Not possible to fast-forward, aborting.",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
GitService, "_token_for_workspace", AsyncMock(return_value=None)
|
||||
)
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValidationError, match="DIVERGED_BRANCH"):
|
||||
await svc.pull(_WORKSPACE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_success_returns_post_pull_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""pull() returns the post-pull status on a clean, fast-forwardable branch.
|
||||
|
||||
The pre-flight ``git status --porcelain`` is clean and ``git pull --ff-only``
|
||||
succeeds, so pull() returns the post-pull ``get_status`` tuple.
|
||||
"""
|
||||
_post_pull: tuple[str, bool, list[str], list[str], list[str], int, int] = (
|
||||
"feature/backend/task",
|
||||
False,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
0,
|
||||
0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
GitService,
|
||||
"_run_git",
|
||||
AsyncMock(side_effect=[_result(stdout=""), _result(returncode=0)]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
GitService, "_token_for_workspace", AsyncMock(return_value=None)
|
||||
)
|
||||
monkeypatch.setattr(GitService, "get_status", AsyncMock(return_value=_post_pull))
|
||||
|
||||
svc = _git_service()
|
||||
result = await svc.pull(_WORKSPACE)
|
||||
|
||||
assert result == _post_pull
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitRebaseRequest.target_branch field validator tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rebase_request_target_branch_dash_prefix_rejected() -> None:
|
||||
"""GitRebaseRequest rejects target_branch that starts with '-'.
|
||||
|
||||
Branch names beginning with '-' are not valid git ref names and look
|
||||
like CLI flags, so the schema validator rejects them with a clear error.
|
||||
"""
|
||||
with pytest.raises(pydantic.ValidationError, match="INVALID_TARGET_BRANCH"):
|
||||
GitRebaseRequest(
|
||||
project_slug="roboco",
|
||||
target_branch="-bad-branch",
|
||||
)
|
||||
|
||||
|
||||
def test_rebase_request_target_branch_protected_name_rejected() -> None:
|
||||
"""GitRebaseRequest rejects target_branch 'main' (a protected branch name)."""
|
||||
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
|
||||
GitRebaseRequest(
|
||||
project_slug="roboco",
|
||||
target_branch="main",
|
||||
)
|
||||
|
||||
|
||||
def test_rebase_request_target_branch_master_rejected() -> None:
|
||||
"""GitRebaseRequest rejects target_branch 'master' (a protected branch name)."""
|
||||
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
|
||||
GitRebaseRequest(
|
||||
project_slug="roboco",
|
||||
target_branch="master",
|
||||
)
|
||||
|
||||
|
||||
def test_rebase_request_valid_target_branch_accepted() -> None:
|
||||
"""GitRebaseRequest accepts a valid, non-protected target_branch."""
|
||||
req = GitRebaseRequest(
|
||||
project_slug="roboco",
|
||||
target_branch="feature/backend/some-task",
|
||||
)
|
||||
assert req.target_branch == "feature/backend/some-task"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level tests: role gate on POST /rebase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _mock_db_generator() -> Any:
|
||||
"""Async generator yielding a MagicMock as the database session.
|
||||
|
||||
FastAPI's original ``get_db`` is an async generator (uses ``yield``).
|
||||
The override must also be a generator (or at least async) so FastAPI
|
||||
handles the dependency lifecycle correctly.
|
||||
"""
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _build_git_app(agent_context: AgentContext) -> FastAPI:
|
||||
"""Minimal FastAPI app with the git router and overridden agent context."""
|
||||
app = FastAPI()
|
||||
app.include_router(git_router, prefix="/git")
|
||||
app.dependency_overrides[get_agent_context] = lambda: agent_context
|
||||
app.dependency_overrides[get_db] = _mock_db_generator
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_endpoint_developer_gets_403() -> None:
|
||||
"""POST /git/rebase returns HTTP 403 for a DEVELOPER-role agent.
|
||||
|
||||
The role gate fires before any service call, so no git service mock
|
||||
is needed.
|
||||
"""
|
||||
agent = AgentContext(agent_id=uuid4(), role=AgentRole.DEVELOPER)
|
||||
app = _build_git_app(agent)
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/git/rebase",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"target_branch": "feature/backend/some-task",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == _HTTP_403
|
||||
detail = response.json()["detail"]
|
||||
assert "REBASE_ROLE_RESTRICTED" in detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_endpoint_pm_gets_200() -> None:
|
||||
"""POST /git/rebase returns HTTP 200 for a CELL_PM-role agent.
|
||||
|
||||
The role gate passes; no task_id is supplied so the ownership check
|
||||
is skipped; project resolution and the git service are patched.
|
||||
"""
|
||||
agent = AgentContext(agent_id=uuid4(), role=AgentRole.CELL_PM)
|
||||
app = _build_git_app(agent)
|
||||
|
||||
# Mock project service → returns a project with slug "roboco"
|
||||
mock_project = MagicMock()
|
||||
mock_project.slug = "roboco"
|
||||
mock_project_svc = MagicMock()
|
||||
mock_project_svc.get_by_slug = AsyncMock(return_value=mock_project)
|
||||
|
||||
# Mock git service → workspace + rebase succeed without conflict
|
||||
mock_git_svc = MagicMock()
|
||||
mock_git_svc.get_workspace = AsyncMock(return_value=Path("/tmp/fake-ws"))
|
||||
mock_git_svc.rebase = AsyncMock(return_value=(False, []))
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"roboco.api.routes.git.get_project_service", return_value=mock_project_svc
|
||||
),
|
||||
patch("roboco.api.routes.git.get_git_service", return_value=mock_git_svc),
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/git/rebase",
|
||||
json={
|
||||
"project_slug": "roboco",
|
||||
"target_branch": "feature/backend/some-task",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == _HTTP_200
|
||||
body = response.json()
|
||||
assert body["project_slug"] == "roboco"
|
||||
assert body["conflict"] is False
|
||||
assert body["conflicted_files"] == []
|
||||
Reference in New Issue
Block a user