code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detail — per-item detail for a pipeline assignment.
|
|
3
|
+
*
|
|
4
|
+
* Like Home, this is *panel content*, not a screen (#1547): the shell renders
|
|
5
|
+
* it into the detail slot — the third column on wide, the whole screen after a
|
|
6
|
+
* drill-in on narrow — and nothing here branches on viewport. App chrome
|
|
7
|
+
* (wordmark, connection state) moved to the shell, which owns exactly one copy
|
|
8
|
+
* of each now that both panels can be on screen at the same time.
|
|
9
|
+
*
|
|
10
|
+
* Shows the assignment header, test-gate actions, review section (findings +
|
|
11
|
+
* verdict), merge section (gate status + merge / force), optional smoke/unstick
|
|
12
|
+
* actions, and a collapsible diff viewer with syntax highlighting.
|
|
13
|
+
*
|
|
14
|
+
* All write operations go through POST /api/pipeline/action; the pipeline data
|
|
15
|
+
* is read from the same ['pipeline'] React-Query cache used by the Home screen
|
|
16
|
+
* so updates are reflected immediately on both screens.
|
|
17
|
+
*
|
|
18
|
+
* Route (#1548): `/pipeline/:repo/:issue[/:tab]`, not `assignment_id` — an
|
|
19
|
+
* issue outlives any one assignment (work, then a fix, then another fix are
|
|
20
|
+
* separate `assignment_id`s for the same issue), and `repo` + `issue` is what
|
|
21
|
+
* a link pasted into a GitHub comment or Slack actually means by "that
|
|
22
|
+
* issue". `:tab` round-trips (so `/pipeline/repo/42/log` is a valid, stable
|
|
23
|
+
* address) but doesn't change what renders yet — the `Overview / Issue / Log
|
|
24
|
+
* / Findings / Summary` tab set is M-W2 scope (`docs/WEB_CONTROL_CENTER.md`);
|
|
25
|
+
* this component still renders its one flowing view regardless of `tab`.
|
|
26
|
+
*/
|
|
27
|
+
import { useCallback, useState } from 'react'
|
|
28
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
29
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
30
|
+
import {
|
|
31
|
+
fetchPipeline,
|
|
32
|
+
fetchDiff,
|
|
33
|
+
pipelineAction,
|
|
34
|
+
type PipelineView,
|
|
35
|
+
type PipelineActionRequest,
|
|
36
|
+
} from '@/api/client'
|
|
37
|
+
import { cn } from '@/lib/utils'
|
|
38
|
+
import { paths } from '@/routes/paths'
|
|
39
|
+
|
|
40
|
+
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface Toast {
|
|
43
|
+
id: string
|
|
44
|
+
message: string
|
|
45
|
+
type: 'success' | 'error'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ToastListProps {
|
|
49
|
+
toasts: Toast[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ToastList({ toasts }: ToastListProps) {
|
|
53
|
+
if (toasts.length === 0) return null
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
role="status"
|
|
57
|
+
aria-live="polite"
|
|
58
|
+
aria-label="Notifications"
|
|
59
|
+
className="fixed bottom-6 left-0 right-0 z-50 flex flex-col items-center gap-2 px-4 pointer-events-none"
|
|
60
|
+
>
|
|
61
|
+
{toasts.map((t) => (
|
|
62
|
+
<div
|
|
63
|
+
key={t.id}
|
|
64
|
+
className={cn(
|
|
65
|
+
'w-full max-w-sm rounded-lg px-4 py-3 text-sm font-medium shadow-lg',
|
|
66
|
+
t.type === 'success'
|
|
67
|
+
? 'bg-green-700 text-white'
|
|
68
|
+
: 'bg-destructive text-destructive-foreground',
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{t.message}
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Fail dialog ───────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
interface FailDialogProps {
|
|
81
|
+
reason: string
|
|
82
|
+
onReasonChange: (v: string) => void
|
|
83
|
+
onConfirm: () => void
|
|
84
|
+
onCancel: () => void
|
|
85
|
+
disabled: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function FailDialog({ reason, onReasonChange, onConfirm, onCancel, disabled }: FailDialogProps) {
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
role="dialog"
|
|
92
|
+
aria-modal="true"
|
|
93
|
+
aria-label="Record test failure"
|
|
94
|
+
className="fixed inset-0 z-40 flex items-end justify-center bg-black/60 px-4 pb-8"
|
|
95
|
+
>
|
|
96
|
+
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-4 shadow-xl">
|
|
97
|
+
<h2 className="mb-3 text-sm font-semibold text-card-foreground">Record test failure</h2>
|
|
98
|
+
<label className="mb-1 block text-xs text-muted-foreground" htmlFor="fail-reason">
|
|
99
|
+
Reason (optional)
|
|
100
|
+
</label>
|
|
101
|
+
<textarea
|
|
102
|
+
id="fail-reason"
|
|
103
|
+
value={reason}
|
|
104
|
+
onChange={(e) => onReasonChange(e.target.value)}
|
|
105
|
+
rows={3}
|
|
106
|
+
placeholder="What failed?"
|
|
107
|
+
className="mb-3 w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
|
108
|
+
/>
|
|
109
|
+
<div className="flex gap-2">
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={onCancel}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
className="flex-1 rounded-lg border border-border py-2 text-sm text-muted-foreground disabled:opacity-50"
|
|
115
|
+
>
|
|
116
|
+
Cancel
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={onConfirm}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
className="flex-1 rounded-lg bg-destructive py-2 text-sm font-medium text-destructive-foreground disabled:opacity-50"
|
|
123
|
+
>
|
|
124
|
+
{disabled ? 'Recording…' : 'Confirm Fail'}
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Diff viewer ───────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
interface DiffViewerProps {
|
|
135
|
+
assignmentId: string
|
|
136
|
+
expanded: boolean
|
|
137
|
+
onToggle: () => void
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function DiffViewer({ assignmentId, expanded, onToggle }: DiffViewerProps) {
|
|
141
|
+
const { data, isLoading, isError } = useQuery({
|
|
142
|
+
queryKey: ['diff', assignmentId],
|
|
143
|
+
queryFn: () => fetchDiff(assignmentId),
|
|
144
|
+
enabled: expanded,
|
|
145
|
+
staleTime: 60_000,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<section className="mt-4" aria-label="Diff viewer">
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={onToggle}
|
|
153
|
+
aria-expanded={expanded}
|
|
154
|
+
className="flex w-full items-center justify-between rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium text-card-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
155
|
+
>
|
|
156
|
+
<span>Diff</span>
|
|
157
|
+
<span className="text-muted-foreground" aria-hidden="true">
|
|
158
|
+
{expanded ? '▲' : '▼'}
|
|
159
|
+
</span>
|
|
160
|
+
</button>
|
|
161
|
+
|
|
162
|
+
{expanded && (
|
|
163
|
+
<div className="mt-1 rounded-lg border border-border bg-black/30 p-3">
|
|
164
|
+
{isLoading && (
|
|
165
|
+
<p className="text-xs text-muted-foreground">Loading diff…</p>
|
|
166
|
+
)}
|
|
167
|
+
{isError && (
|
|
168
|
+
<p className="text-xs text-destructive">Failed to load diff</p>
|
|
169
|
+
)}
|
|
170
|
+
{data && (
|
|
171
|
+
<>
|
|
172
|
+
<pre className="overflow-x-auto text-xs leading-5" aria-label="Code diff">
|
|
173
|
+
{data.diff.split('\n').map((line, i) => (
|
|
174
|
+
<span
|
|
175
|
+
key={i}
|
|
176
|
+
className={cn(
|
|
177
|
+
'block',
|
|
178
|
+
line.startsWith('+++') || line.startsWith('---')
|
|
179
|
+
? 'text-muted-foreground'
|
|
180
|
+
: line.startsWith('+')
|
|
181
|
+
? 'text-green-400'
|
|
182
|
+
: line.startsWith('-')
|
|
183
|
+
? 'text-red-400'
|
|
184
|
+
: line.startsWith('@@')
|
|
185
|
+
? 'text-blue-400'
|
|
186
|
+
: 'text-foreground',
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{line || ' '}
|
|
190
|
+
</span>
|
|
191
|
+
))}
|
|
192
|
+
</pre>
|
|
193
|
+
<p className="mt-2 text-right text-xs text-muted-foreground">
|
|
194
|
+
source: {data.source}
|
|
195
|
+
</p>
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</section>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Stage display helpers (mirrors PipelineCard) ──────────────────────────────
|
|
205
|
+
|
|
206
|
+
const STAGE_LABEL: Record<string, string> = {
|
|
207
|
+
coding: 'work',
|
|
208
|
+
smoke: 'test',
|
|
209
|
+
review: 'review',
|
|
210
|
+
merge: 'merge',
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const FAILED_STAGES = new Set(['failed', 'review_failed', 'smoke_failed'])
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The detail's own content column (#1547).
|
|
217
|
+
*
|
|
218
|
+
* `max-w-3xl` rather than the old `max-w-lg`: this is now the *detail slot* of
|
|
219
|
+
* the shell, which on a wide viewport is whatever is left after the rail and
|
|
220
|
+
* the list — a 512px column stranded in the middle of 1000px of empty ground
|
|
221
|
+
* looked like a rendering bug. On a phone every candidate max-width exceeds
|
|
222
|
+
* the viewport, so `px-4` is what actually sets the measure there and the
|
|
223
|
+
* layout is byte-for-byte what it was.
|
|
224
|
+
*/
|
|
225
|
+
const detailShellClass = 'mx-auto w-full max-w-3xl px-4 py-5 md:px-6'
|
|
226
|
+
|
|
227
|
+
function stageStatus(currentStage: string): { label: string; className: string } {
|
|
228
|
+
const RUNNING = new Set(['coding', 'review_running', 'smoke_running', 'merging'])
|
|
229
|
+
if (RUNNING.has(currentStage)) {
|
|
230
|
+
return { label: 'running', className: 'bg-primary text-primary-foreground' }
|
|
231
|
+
}
|
|
232
|
+
if (FAILED_STAGES.has(currentStage)) {
|
|
233
|
+
return { label: 'failed', className: 'bg-destructive text-destructive-foreground' }
|
|
234
|
+
}
|
|
235
|
+
switch (currentStage) {
|
|
236
|
+
case 'merged': return { label: 'merged', className: 'bg-green-700 text-white' }
|
|
237
|
+
case 'merge_ready': return { label: 'mergeable', className: 'bg-yellow-600 text-black' }
|
|
238
|
+
case 'review_done': return { label: 'review ✓', className: 'bg-yellow-600 text-black' }
|
|
239
|
+
case 'smoke_passed':return { label: 'test ✓', className: 'bg-yellow-600 text-black' }
|
|
240
|
+
case 'done': return { label: 'work done', className: 'bg-yellow-600 text-black' }
|
|
241
|
+
default: return { label: currentStage.replace(/_/g, ' '), className: 'bg-secondary text-secondary-foreground' }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Main component ────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export default function Detail() {
|
|
248
|
+
const { repo, issue } = useParams<{ repo: string; issue: string }>()
|
|
249
|
+
const navigate = useNavigate()
|
|
250
|
+
const queryClient = useQueryClient()
|
|
251
|
+
|
|
252
|
+
// Pipeline data — shared cache key with Home screen. #1549: no
|
|
253
|
+
// refetchInterval -- RealtimeProvider invalidates ['pipeline'] on the
|
|
254
|
+
// relevant SSE events, so this screen updates the same way Home does.
|
|
255
|
+
const { data: pipeline, isLoading, isError } = useQuery({
|
|
256
|
+
queryKey: ['pipeline'],
|
|
257
|
+
queryFn: fetchPipeline,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Keyed on repo + issue_number (#1548), not assignment_id — see the route
|
|
261
|
+
// comment above. `issue` arrives as a route-param string; PipelineView's
|
|
262
|
+
// `issue_number` is a number, so the comparison converts rather than the
|
|
263
|
+
// reverse (a leading-zero or malformed URL segment must fail the match,
|
|
264
|
+
// not silently coerce to some other issue's number).
|
|
265
|
+
const view: PipelineView | null =
|
|
266
|
+
pipeline?.find((v) => v.repo_name === repo && String(v.issue_number) === issue) ?? null
|
|
267
|
+
|
|
268
|
+
// `navigate(-1)` is a silent no-op when there is no in-app history entry to
|
|
269
|
+
// pop -- exactly what happens on a cold deep-link load (#1551): a user
|
|
270
|
+
// follows a shared `/pipeline/repo/42` link (a bookmark, a notification, a
|
|
271
|
+
// pasted Slack URL -- precisely what #1548's route shape exists to make
|
|
272
|
+
// possible) directly into Detail, with nothing before it in the tab's
|
|
273
|
+
// history, and Back does nothing, stranding them here. React Router's
|
|
274
|
+
// browser history stamps `window.history.state.idx` with this tab's own
|
|
275
|
+
// navigation-stack position (0 at the very first entry it ever
|
|
276
|
+
// pushed/replaced); `idx > 0` means there really is a previous in-app entry
|
|
277
|
+
// to go back to. Falling back to the pipeline list route (rather than
|
|
278
|
+
// leaving the button a no-op, or leaving the SPA via `window.history.back()`
|
|
279
|
+
// straight to whatever opened the tab) matches what Back already means on
|
|
280
|
+
// wide -- "clear the detail selection, stay on the list".
|
|
281
|
+
const handleBack = useCallback(() => {
|
|
282
|
+
const idx = (window.history.state as { idx?: number } | null)?.idx
|
|
283
|
+
if (typeof idx === 'number' && idx > 0) navigate(-1)
|
|
284
|
+
else navigate(paths.pipeline())
|
|
285
|
+
}, [navigate])
|
|
286
|
+
|
|
287
|
+
// UI state
|
|
288
|
+
const [diffExpanded, setDiffExpanded] = useState(false)
|
|
289
|
+
const [inFlight, setInFlight] = useState<string | null>(null)
|
|
290
|
+
const [toasts, setToasts] = useState<Toast[]>([])
|
|
291
|
+
const [failDialogOpen, setFailDialogOpen] = useState(false)
|
|
292
|
+
const [failReason, setFailReason] = useState('')
|
|
293
|
+
const [forceMode, setForceMode] = useState(false)
|
|
294
|
+
|
|
295
|
+
// Toast helpers
|
|
296
|
+
const addToast = useCallback((message: string, type: 'success' | 'error') => {
|
|
297
|
+
const toastId = String(Date.now())
|
|
298
|
+
setToasts((prev) => [...prev, { id: toastId, message, type }])
|
|
299
|
+
setTimeout(() => {
|
|
300
|
+
setToasts((prev) => prev.filter((t) => t.id !== toastId))
|
|
301
|
+
}, 4_000)
|
|
302
|
+
}, [])
|
|
303
|
+
|
|
304
|
+
// Generic action handler — sets in-flight, calls API, shows toast, invalidates cache
|
|
305
|
+
const doAction = useCallback(
|
|
306
|
+
async (req: PipelineActionRequest, key: string, successMsg?: string) => {
|
|
307
|
+
if (inFlight) return
|
|
308
|
+
setInFlight(key)
|
|
309
|
+
try {
|
|
310
|
+
const result = await pipelineAction(req)
|
|
311
|
+
if (result.ok) {
|
|
312
|
+
addToast(successMsg ?? 'Done', 'success')
|
|
313
|
+
void queryClient.invalidateQueries({ queryKey: ['pipeline'] })
|
|
314
|
+
} else {
|
|
315
|
+
addToast(result.error ?? 'Action failed', 'error')
|
|
316
|
+
}
|
|
317
|
+
} catch (e) {
|
|
318
|
+
addToast(e instanceof Error ? e.message : 'Action failed', 'error')
|
|
319
|
+
} finally {
|
|
320
|
+
setInFlight(null)
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
[inFlight, addToast, queryClient],
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
// Gate availability helper
|
|
327
|
+
const hasGate = (action: string): boolean =>
|
|
328
|
+
view?.available_gates.some((g) => g.action === action) ?? false
|
|
329
|
+
|
|
330
|
+
// Shared button base class
|
|
331
|
+
const btnBase =
|
|
332
|
+
'rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
333
|
+
|
|
334
|
+
// ── Loading / error / not-found ───────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
if (isLoading) {
|
|
337
|
+
return (
|
|
338
|
+
<div className={detailShellClass}>
|
|
339
|
+
<p className="py-12 text-center text-sm text-muted-foreground">Loading…</p>
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (isError) {
|
|
345
|
+
return (
|
|
346
|
+
<div className={detailShellClass}>
|
|
347
|
+
<p className="py-12 text-center text-sm text-destructive">Failed to load pipeline</p>
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!view) {
|
|
353
|
+
return (
|
|
354
|
+
<div className={detailShellClass}>
|
|
355
|
+
<header className="mb-6 flex items-center gap-3">
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={handleBack}
|
|
359
|
+
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
|
360
|
+
aria-label="Back"
|
|
361
|
+
>
|
|
362
|
+
←
|
|
363
|
+
</button>
|
|
364
|
+
<h1 className="text-step-1 font-semibold text-foreground">Not found</h1>
|
|
365
|
+
</header>
|
|
366
|
+
<p className="text-sm text-muted-foreground">
|
|
367
|
+
Issue{' '}
|
|
368
|
+
<span className="font-mono">
|
|
369
|
+
{repo}#{issue}
|
|
370
|
+
</span>{' '}
|
|
371
|
+
not found in the pipeline.
|
|
372
|
+
</p>
|
|
373
|
+
</div>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const { label: statusLabel, className: statusClass } = stageStatus(view.current_stage)
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<div className={cn(detailShellClass, 'pb-24')}>
|
|
381
|
+
<ToastList toasts={toasts} />
|
|
382
|
+
|
|
383
|
+
{failDialogOpen && (
|
|
384
|
+
<FailDialog
|
|
385
|
+
reason={failReason}
|
|
386
|
+
onReasonChange={setFailReason}
|
|
387
|
+
onConfirm={() => {
|
|
388
|
+
const req: PipelineActionRequest = {
|
|
389
|
+
assignment_id: view.assignment_id,
|
|
390
|
+
action: 'test-verdict',
|
|
391
|
+
verdict: 'failed',
|
|
392
|
+
...(failReason.trim() ? { reason: failReason.trim() } : {}),
|
|
393
|
+
}
|
|
394
|
+
void doAction(req, 'test-fail', 'Test marked failed').then(() => {
|
|
395
|
+
setFailDialogOpen(false)
|
|
396
|
+
setFailReason('')
|
|
397
|
+
})
|
|
398
|
+
}}
|
|
399
|
+
onCancel={() => {
|
|
400
|
+
setFailDialogOpen(false)
|
|
401
|
+
setFailReason('')
|
|
402
|
+
}}
|
|
403
|
+
disabled={inFlight === 'test-fail'}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{/* ── Header ────────────────────────────────────────────────────────── */}
|
|
408
|
+
<header className="mb-5">
|
|
409
|
+
<div className="flex items-start gap-2">
|
|
410
|
+
{/* Back is the phone's drill-out and the wide layout's "clear the
|
|
411
|
+
selection" — `handleBack` pops in-app history when there is any
|
|
412
|
+
(the previous entry is the list at `/` either way), and falls
|
|
413
|
+
back to the pipeline list route on a cold deep-link load, where
|
|
414
|
+
there is none (#1551). */}
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={handleBack}
|
|
418
|
+
aria-label="Back"
|
|
419
|
+
className="-ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
420
|
+
>
|
|
421
|
+
←
|
|
422
|
+
</button>
|
|
423
|
+
<div className="min-w-0 flex-1">
|
|
424
|
+
<p className="text-xs text-muted-foreground">
|
|
425
|
+
<span className="font-mono">{view.repo_name}</span>{' '}
|
|
426
|
+
<span className="font-mono">#{view.issue_number}</span>
|
|
427
|
+
{' · '}
|
|
428
|
+
<span className="font-mono">{view.machine_name}</span>
|
|
429
|
+
</p>
|
|
430
|
+
<h2 className="mt-0.5 text-base font-semibold text-foreground">
|
|
431
|
+
{view.issue_title}
|
|
432
|
+
</h2>
|
|
433
|
+
</div>
|
|
434
|
+
<span
|
|
435
|
+
className={cn(
|
|
436
|
+
'shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold',
|
|
437
|
+
statusClass,
|
|
438
|
+
)}
|
|
439
|
+
>
|
|
440
|
+
{statusLabel}
|
|
441
|
+
</span>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{/* Stage chips */}
|
|
445
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
446
|
+
{view.stages.map((stage) => {
|
|
447
|
+
const base = 'rounded px-1.5 py-0.5 text-xs font-medium'
|
|
448
|
+
let cls: string
|
|
449
|
+
if (stage.is_current) {
|
|
450
|
+
cls = FAILED_STAGES.has(view.current_stage)
|
|
451
|
+
? cn(base, 'bg-destructive text-destructive-foreground')
|
|
452
|
+
: cn(base, 'bg-primary text-primary-foreground')
|
|
453
|
+
} else if (stage.status === 'completed') {
|
|
454
|
+
cls = cn(base, 'bg-green-700 text-white')
|
|
455
|
+
} else if (stage.status === 'skipped') {
|
|
456
|
+
cls = cn(base, 'border border-border text-muted-foreground opacity-40')
|
|
457
|
+
} else {
|
|
458
|
+
cls = cn(base, 'border border-border text-muted-foreground')
|
|
459
|
+
}
|
|
460
|
+
return (
|
|
461
|
+
<span key={stage.name} className={cls}>
|
|
462
|
+
{STAGE_LABEL[stage.name] ?? stage.name}
|
|
463
|
+
</span>
|
|
464
|
+
)
|
|
465
|
+
})}
|
|
466
|
+
</div>
|
|
467
|
+
</header>
|
|
468
|
+
|
|
469
|
+
{/* ── Test gate ─────────────────────────────────────────────────────── */}
|
|
470
|
+
{(hasGate('test-verdict') || view.test_verdict != null) && (
|
|
471
|
+
<section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Test gate">
|
|
472
|
+
<h3 className="mb-3 text-sm font-semibold text-card-foreground">Test</h3>
|
|
473
|
+
|
|
474
|
+
{view.test_verdict && (
|
|
475
|
+
<p
|
|
476
|
+
className={cn(
|
|
477
|
+
'mb-3 text-sm font-medium',
|
|
478
|
+
view.test_verdict === 'passed' ? 'text-green-400' : 'text-destructive',
|
|
479
|
+
)}
|
|
480
|
+
>
|
|
481
|
+
Verdict: {view.test_verdict}
|
|
482
|
+
</p>
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{hasGate('test-verdict') && (
|
|
486
|
+
<div className="flex gap-2">
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
onClick={() =>
|
|
490
|
+
doAction(
|
|
491
|
+
{ assignment_id: view.assignment_id, action: 'test-verdict', verdict: 'passed' },
|
|
492
|
+
'test-pass',
|
|
493
|
+
'Test marked passed',
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
disabled={!!inFlight}
|
|
497
|
+
className={cn(btnBase, 'flex-1 bg-green-700 text-white')}
|
|
498
|
+
>
|
|
499
|
+
{inFlight === 'test-pass' ? 'Recording…' : 'Pass'}
|
|
500
|
+
</button>
|
|
501
|
+
<button
|
|
502
|
+
type="button"
|
|
503
|
+
onClick={() => setFailDialogOpen(true)}
|
|
504
|
+
disabled={!!inFlight}
|
|
505
|
+
className={cn(btnBase, 'flex-1 bg-destructive text-destructive-foreground')}
|
|
506
|
+
>
|
|
507
|
+
Fail
|
|
508
|
+
</button>
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
|
|
512
|
+
{/* Dispatch-fix offered when test failed */}
|
|
513
|
+
{hasGate('dispatch_fix') && view.test_verdict === 'failed' && (
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
onClick={() =>
|
|
517
|
+
doAction(
|
|
518
|
+
{ assignment_id: view.assignment_id, action: 'dispatch_fix' },
|
|
519
|
+
'dispatch-fix',
|
|
520
|
+
'Fix dispatched',
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
disabled={!!inFlight}
|
|
524
|
+
className={cn(btnBase, 'mt-3 w-full border border-border text-foreground')}
|
|
525
|
+
>
|
|
526
|
+
{inFlight === 'dispatch-fix' ? 'Dispatching…' : 'Dispatch Fix'}
|
|
527
|
+
</button>
|
|
528
|
+
)}
|
|
529
|
+
</section>
|
|
530
|
+
)}
|
|
531
|
+
|
|
532
|
+
{/* ── Dispatch review ───────────────────────────────────────────────── */}
|
|
533
|
+
{hasGate('dispatch_review') && (
|
|
534
|
+
<section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Review">
|
|
535
|
+
<h3 className="mb-3 text-sm font-semibold text-card-foreground">Review</h3>
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() =>
|
|
539
|
+
doAction(
|
|
540
|
+
{ assignment_id: view.assignment_id, action: 'dispatch_review' },
|
|
541
|
+
'dispatch-review',
|
|
542
|
+
'Review dispatched',
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
disabled={!!inFlight}
|
|
546
|
+
className={cn(btnBase, 'w-full bg-primary text-primary-foreground')}
|
|
547
|
+
>
|
|
548
|
+
{inFlight === 'dispatch-review' ? 'Dispatching…' : 'Start Review'}
|
|
549
|
+
</button>
|
|
550
|
+
</section>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
{/* ── Review findings + verdict ──────────────────────────────────────── */}
|
|
554
|
+
{!hasGate('dispatch_review') &&
|
|
555
|
+
(view.review_findings_body !== null ||
|
|
556
|
+
view.review_verdict !== null ||
|
|
557
|
+
hasGate('record-review-verdict') ||
|
|
558
|
+
hasGate('post_findings')) && (
|
|
559
|
+
<section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Review">
|
|
560
|
+
<h3 className="mb-3 text-sm font-semibold text-card-foreground">Review</h3>
|
|
561
|
+
|
|
562
|
+
{view.review_verdict && (
|
|
563
|
+
<p
|
|
564
|
+
className={cn(
|
|
565
|
+
'mb-3 text-sm font-medium',
|
|
566
|
+
view.review_verdict === 'approve' ? 'text-green-400' : 'text-yellow-500',
|
|
567
|
+
)}
|
|
568
|
+
>
|
|
569
|
+
Verdict:{' '}
|
|
570
|
+
{view.review_verdict === 'approve' ? 'Approved' : 'Changes requested'}
|
|
571
|
+
</p>
|
|
572
|
+
)}
|
|
573
|
+
|
|
574
|
+
{view.review_findings_body && (
|
|
575
|
+
<div className="mb-3">
|
|
576
|
+
<p className="mb-1 text-xs text-muted-foreground">Findings</p>
|
|
577
|
+
<pre className="max-h-64 overflow-y-auto rounded border border-border bg-black/30 px-3 py-2 text-xs leading-5 text-foreground whitespace-pre-wrap break-words">
|
|
578
|
+
{view.review_findings_body}
|
|
579
|
+
</pre>
|
|
580
|
+
</div>
|
|
581
|
+
)}
|
|
582
|
+
|
|
583
|
+
{view.review_findings_pending && (
|
|
584
|
+
<div className="mb-3 flex items-center justify-between gap-2">
|
|
585
|
+
<p className="text-xs text-yellow-500">⚠ Findings not yet posted to GitHub</p>
|
|
586
|
+
{hasGate('post_findings') && (
|
|
587
|
+
<button
|
|
588
|
+
type="button"
|
|
589
|
+
onClick={() =>
|
|
590
|
+
doAction(
|
|
591
|
+
{ assignment_id: view.assignment_id, action: 'post_findings' },
|
|
592
|
+
'post-findings',
|
|
593
|
+
'Findings posted',
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
disabled={!!inFlight}
|
|
597
|
+
className="rounded border border-yellow-600 px-2 py-1 text-xs text-yellow-500 disabled:opacity-50"
|
|
598
|
+
>
|
|
599
|
+
{inFlight === 'post-findings' ? 'Posting…' : 'Post'}
|
|
600
|
+
</button>
|
|
601
|
+
)}
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
|
|
605
|
+
{hasGate('record-review-verdict') && (
|
|
606
|
+
<div className="flex gap-2">
|
|
607
|
+
<button
|
|
608
|
+
type="button"
|
|
609
|
+
onClick={() =>
|
|
610
|
+
doAction(
|
|
611
|
+
{
|
|
612
|
+
assignment_id: view.assignment_id,
|
|
613
|
+
action: 'record-review-verdict',
|
|
614
|
+
verdict: 'approve',
|
|
615
|
+
},
|
|
616
|
+
'approve',
|
|
617
|
+
'Review approved',
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
disabled={!!inFlight}
|
|
621
|
+
className={cn(btnBase, 'flex-1 bg-green-700 text-white')}
|
|
622
|
+
>
|
|
623
|
+
{inFlight === 'approve' ? 'Approving…' : 'Approve'}
|
|
624
|
+
</button>
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
onClick={() =>
|
|
628
|
+
doAction(
|
|
629
|
+
{ assignment_id: view.assignment_id, action: 'dispatch_fix' },
|
|
630
|
+
'request-changes',
|
|
631
|
+
'Fix dispatched',
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
disabled={!!inFlight}
|
|
635
|
+
className={cn(btnBase, 'flex-1 bg-destructive text-destructive-foreground')}
|
|
636
|
+
>
|
|
637
|
+
{inFlight === 'request-changes' ? 'Dispatching…' : 'Request Changes'}
|
|
638
|
+
</button>
|
|
639
|
+
</div>
|
|
640
|
+
)}
|
|
641
|
+
</section>
|
|
642
|
+
)}
|
|
643
|
+
|
|
644
|
+
{/* ── Merge ─────────────────────────────────────────────────────────── */}
|
|
645
|
+
{(hasGate('merge') ||
|
|
646
|
+
hasGate('enqueue') ||
|
|
647
|
+
['merge_ready', 'merging', 'merged'].includes(view.current_stage)) && (
|
|
648
|
+
<section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Merge">
|
|
649
|
+
<h3 className="mb-3 text-sm font-semibold text-card-foreground">Merge</h3>
|
|
650
|
+
|
|
651
|
+
{/* Gate status list */}
|
|
652
|
+
<div className="mb-3 space-y-1.5">
|
|
653
|
+
{view.stages.map((stage) => (
|
|
654
|
+
<div key={stage.name} className="flex items-center gap-2 text-xs">
|
|
655
|
+
<span
|
|
656
|
+
className={cn(
|
|
657
|
+
'h-1.5 w-1.5 shrink-0 rounded-full',
|
|
658
|
+
stage.status === 'completed' || stage.status === 'skipped'
|
|
659
|
+
? 'bg-green-500'
|
|
660
|
+
: stage.is_current
|
|
661
|
+
? 'bg-primary'
|
|
662
|
+
: 'bg-border',
|
|
663
|
+
)}
|
|
664
|
+
/>
|
|
665
|
+
<span className="text-muted-foreground">{STAGE_LABEL[stage.name] ?? stage.name}</span>
|
|
666
|
+
<span
|
|
667
|
+
className={cn(
|
|
668
|
+
stage.status === 'completed' || stage.status === 'skipped'
|
|
669
|
+
? 'text-green-400'
|
|
670
|
+
: stage.is_current
|
|
671
|
+
? 'text-primary'
|
|
672
|
+
: 'text-muted-foreground',
|
|
673
|
+
)}
|
|
674
|
+
>
|
|
675
|
+
{stage.status}
|
|
676
|
+
</span>
|
|
677
|
+
</div>
|
|
678
|
+
))}
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
{view.current_stage === 'merged' ? (
|
|
682
|
+
<p className="text-sm font-medium text-green-400">✓ Merged</p>
|
|
683
|
+
) : (
|
|
684
|
+
<>
|
|
685
|
+
{hasGate('merge') && (
|
|
686
|
+
<label className="mb-3 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
|
687
|
+
<input
|
|
688
|
+
type="checkbox"
|
|
689
|
+
checked={forceMode}
|
|
690
|
+
onChange={(e) => setForceMode(e.target.checked)}
|
|
691
|
+
className="h-3.5 w-3.5"
|
|
692
|
+
/>
|
|
693
|
+
Force (skip CI / review checks)
|
|
694
|
+
</label>
|
|
695
|
+
)}
|
|
696
|
+
|
|
697
|
+
<div className="flex gap-2">
|
|
698
|
+
{hasGate('enqueue') && (
|
|
699
|
+
<button
|
|
700
|
+
type="button"
|
|
701
|
+
onClick={() =>
|
|
702
|
+
doAction(
|
|
703
|
+
{ assignment_id: view.assignment_id, action: 'enqueue' },
|
|
704
|
+
'enqueue',
|
|
705
|
+
'Added to merge queue',
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
disabled={!!inFlight}
|
|
709
|
+
className={cn(btnBase, 'flex-1 border border-border text-foreground')}
|
|
710
|
+
>
|
|
711
|
+
{inFlight === 'enqueue' ? 'Queuing…' : 'Enqueue'}
|
|
712
|
+
</button>
|
|
713
|
+
)}
|
|
714
|
+
{hasGate('merge') && (
|
|
715
|
+
<button
|
|
716
|
+
type="button"
|
|
717
|
+
onClick={() =>
|
|
718
|
+
doAction(
|
|
719
|
+
{
|
|
720
|
+
assignment_id: view.assignment_id,
|
|
721
|
+
action: 'merge',
|
|
722
|
+
...(forceMode ? { force: true } : {}),
|
|
723
|
+
},
|
|
724
|
+
'merge',
|
|
725
|
+
'Merged successfully',
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
disabled={!!inFlight}
|
|
729
|
+
className={cn(
|
|
730
|
+
btnBase,
|
|
731
|
+
'flex-1',
|
|
732
|
+
forceMode
|
|
733
|
+
? 'bg-yellow-600 text-black'
|
|
734
|
+
: 'bg-primary text-primary-foreground',
|
|
735
|
+
)}
|
|
736
|
+
>
|
|
737
|
+
{inFlight === 'merge'
|
|
738
|
+
? 'Merging…'
|
|
739
|
+
: forceMode
|
|
740
|
+
? 'Force Merge'
|
|
741
|
+
: 'Merge'}
|
|
742
|
+
</button>
|
|
743
|
+
)}
|
|
744
|
+
</div>
|
|
745
|
+
</>
|
|
746
|
+
)}
|
|
747
|
+
</section>
|
|
748
|
+
)}
|
|
749
|
+
|
|
750
|
+
{/* ── Dispatch smoke test ────────────────────────────────────────────── */}
|
|
751
|
+
{hasGate('dispatch_smoke') && (
|
|
752
|
+
<section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Smoke test">
|
|
753
|
+
<h3 className="mb-3 text-sm font-semibold text-card-foreground">Smoke Test</h3>
|
|
754
|
+
<button
|
|
755
|
+
type="button"
|
|
756
|
+
onClick={() =>
|
|
757
|
+
doAction(
|
|
758
|
+
{ assignment_id: view.assignment_id, action: 'dispatch_smoke' },
|
|
759
|
+
'dispatch-smoke',
|
|
760
|
+
'Smoke test dispatched',
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
disabled={!!inFlight}
|
|
764
|
+
className={cn(btnBase, 'w-full bg-primary text-primary-foreground')}
|
|
765
|
+
>
|
|
766
|
+
{inFlight === 'dispatch-smoke' ? 'Dispatching…' : 'Start Smoke Test'}
|
|
767
|
+
</button>
|
|
768
|
+
</section>
|
|
769
|
+
)}
|
|
770
|
+
|
|
771
|
+
{/* ── Unstick ───────────────────────────────────────────────────────── */}
|
|
772
|
+
{hasGate('unstick') && (
|
|
773
|
+
<section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Stuck assignment">
|
|
774
|
+
<h3 className="mb-3 text-sm font-semibold text-card-foreground">Stuck</h3>
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
onClick={() =>
|
|
778
|
+
doAction(
|
|
779
|
+
{ assignment_id: view.assignment_id, action: 'unstick' },
|
|
780
|
+
'unstick',
|
|
781
|
+
'Assignment cancelled',
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
disabled={!!inFlight}
|
|
785
|
+
className={cn(btnBase, 'w-full border border-destructive text-destructive')}
|
|
786
|
+
>
|
|
787
|
+
{inFlight === 'unstick' ? 'Cancelling…' : 'Cancel (unstick)'}
|
|
788
|
+
</button>
|
|
789
|
+
</section>
|
|
790
|
+
)}
|
|
791
|
+
|
|
792
|
+
{/* ── Diff viewer ───────────────────────────────────────────────────── */}
|
|
793
|
+
<DiffViewer
|
|
794
|
+
assignmentId={view.assignment_id}
|
|
795
|
+
expanded={diffExpanded}
|
|
796
|
+
onToggle={() => setDiffExpanded((v) => !v)}
|
|
797
|
+
/>
|
|
798
|
+
</div>
|
|
799
|
+
)
|
|
800
|
+
}
|