orbital-command 0.1.4 → 0.3.0
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.
- package/bin/orbital.js +676 -53
- package/dist/assets/PrimitivesConfig-CrmQXYh4.js +32 -0
- package/dist/assets/QualityGates-BbasOsF3.js +21 -0
- package/dist/assets/SessionTimeline-CGeJsVvy.js +1 -0
- package/dist/assets/Settings-oiM496mc.js +12 -0
- package/dist/assets/SourceControl-B1fP2nJL.js +41 -0
- package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +74 -0
- package/dist/assets/arrow-down-CPy85_J6.js +6 -0
- package/dist/assets/charts-DbDg0Psc.js +68 -0
- package/dist/assets/circle-x-Cwz6ZQDV.js +6 -0
- package/dist/assets/file-text-C46Xr65c.js +6 -0
- package/dist/assets/formatDistanceToNow-BMqsSP44.js +1 -0
- package/dist/assets/globe-Cn2yNZUD.js +6 -0
- package/dist/assets/index-Aj4sV8Al.css +1 -0
- package/dist/assets/index-Bc9dK3MW.js +354 -0
- package/dist/assets/key-OPaNTWJ5.js +6 -0
- package/dist/assets/minus-GMsbpKym.js +6 -0
- package/dist/assets/shield-DwAFkDYI.js +6 -0
- package/dist/assets/ui-BmsSg9jU.js +53 -0
- package/dist/assets/useWorkflowEditor-BJkTX_NR.js +16 -0
- package/dist/assets/{vendor-Dzv9lrRc.js → vendor-Bqt8AJn2.js} +1 -1
- package/dist/assets/zap-DfbUoOty.js +11 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +6 -5
- package/dist/server/server/__tests__/data-routes.test.js +124 -0
- package/dist/server/server/__tests__/helpers/db.js +17 -0
- package/dist/server/server/__tests__/helpers/mock-emitter.js +8 -0
- package/dist/server/server/__tests__/scope-routes.test.js +137 -0
- package/dist/server/server/__tests__/sprint-routes.test.js +102 -0
- package/dist/server/server/__tests__/workflow-routes.test.js +107 -0
- package/dist/server/server/config-migrator.js +138 -0
- package/dist/server/server/config.js +17 -2
- package/dist/server/server/database.js +27 -12
- package/dist/server/server/global-config.js +143 -0
- package/dist/server/server/index.js +882 -252
- package/dist/server/server/init.js +579 -194
- package/dist/server/server/launch.js +29 -0
- package/dist/server/server/manifest-types.js +8 -0
- package/dist/server/server/manifest.js +454 -0
- package/dist/server/server/migrate-legacy.js +229 -0
- package/dist/server/server/parsers/event-parser.test.js +117 -0
- package/dist/server/server/parsers/scope-parser.js +74 -28
- package/dist/server/server/parsers/scope-parser.test.js +230 -0
- package/dist/server/server/project-context.js +255 -0
- package/dist/server/server/project-emitter.js +41 -0
- package/dist/server/server/project-manager.js +297 -0
- package/dist/server/server/routes/config-routes.js +1 -3
- package/dist/server/server/routes/data-routes.js +22 -110
- package/dist/server/server/routes/dispatch-routes.js +15 -9
- package/dist/server/server/routes/git-routes.js +74 -0
- package/dist/server/server/routes/manifest-routes.js +319 -0
- package/dist/server/server/routes/scope-routes.js +37 -23
- package/dist/server/server/routes/sync-routes.js +134 -0
- package/dist/server/server/routes/version-routes.js +1 -15
- package/dist/server/server/routes/workflow-routes.js +9 -3
- package/dist/server/server/schema.js +2 -0
- package/dist/server/server/services/batch-orchestrator.js +26 -16
- package/dist/server/server/services/claude-session-service.js +17 -14
- package/dist/server/server/services/deploy-service.test.js +119 -0
- package/dist/server/server/services/event-service.js +64 -1
- package/dist/server/server/services/event-service.test.js +191 -0
- package/dist/server/server/services/gate-service.test.js +105 -0
- package/dist/server/server/services/git-service.js +108 -4
- package/dist/server/server/services/github-service.js +110 -2
- package/dist/server/server/services/readiness-service.test.js +190 -0
- package/dist/server/server/services/scope-cache.js +5 -1
- package/dist/server/server/services/scope-cache.test.js +142 -0
- package/dist/server/server/services/scope-service.js +217 -126
- package/dist/server/server/services/scope-service.test.js +137 -0
- package/dist/server/server/services/sprint-orchestrator.js +7 -6
- package/dist/server/server/services/sprint-service.js +21 -1
- package/dist/server/server/services/sprint-service.test.js +238 -0
- package/dist/server/server/services/sync-service.js +434 -0
- package/dist/server/server/services/sync-types.js +2 -0
- package/dist/server/server/services/telemetry-service.js +143 -0
- package/dist/server/server/services/workflow-service.js +26 -5
- package/dist/server/server/services/workflow-service.test.js +159 -0
- package/dist/server/server/settings-sync.js +284 -0
- package/dist/server/server/update-planner.js +279 -0
- package/dist/server/server/utils/cc-hooks-parser.js +3 -0
- package/dist/server/server/utils/cc-hooks-parser.test.js +86 -0
- package/dist/server/server/utils/dispatch-utils.js +77 -20
- package/dist/server/server/utils/dispatch-utils.test.js +182 -0
- package/dist/server/server/utils/logger.js +37 -3
- package/dist/server/server/utils/package-info.js +30 -0
- package/dist/server/server/utils/route-helpers.js +10 -0
- package/dist/server/server/utils/terminal-launcher.js +79 -25
- package/dist/server/server/utils/worktree-manager.js +13 -4
- package/dist/server/server/validator.js +230 -0
- package/dist/server/server/watchers/global-watcher.js +63 -0
- package/dist/server/server/watchers/scope-watcher.js +27 -12
- package/dist/server/server/wizard/config-editor.js +237 -0
- package/dist/server/server/wizard/detect.js +96 -0
- package/dist/server/server/wizard/doctor.js +115 -0
- package/dist/server/server/wizard/index.js +155 -0
- package/dist/server/server/wizard/phases/confirm.js +39 -0
- package/dist/server/server/wizard/phases/project-setup.js +90 -0
- package/dist/server/server/wizard/phases/setup-wizard.js +66 -0
- package/dist/server/server/wizard/phases/welcome.js +35 -0
- package/dist/server/server/wizard/phases/workflow-setup.js +22 -0
- package/dist/server/server/wizard/types.js +29 -0
- package/dist/server/server/wizard/ui.js +74 -0
- package/dist/server/shared/__fixtures__/workflow-configs.js +75 -0
- package/dist/server/shared/default-workflow.json +65 -0
- package/dist/server/shared/onboarding-tour.test.js +81 -0
- package/dist/server/shared/project-colors.js +24 -0
- package/dist/server/shared/workflow-config.test.js +84 -0
- package/dist/server/shared/workflow-engine.test.js +302 -0
- package/dist/server/shared/workflow-normalizer.js +101 -0
- package/dist/server/shared/workflow-normalizer.test.js +100 -0
- package/dist/server/src/components/onboarding/tour-steps.js +84 -0
- package/package.json +20 -15
- package/schemas/orbital.config.schema.json +16 -1
- package/scripts/postinstall.js +55 -7
- package/server/__tests__/data-routes.test.ts +149 -0
- package/server/__tests__/helpers/db.ts +19 -0
- package/server/__tests__/helpers/mock-emitter.ts +10 -0
- package/server/__tests__/scope-routes.test.ts +157 -0
- package/server/__tests__/sprint-routes.test.ts +118 -0
- package/server/__tests__/workflow-routes.test.ts +120 -0
- package/server/config-migrator.ts +163 -0
- package/server/config.ts +26 -2
- package/server/database.ts +35 -18
- package/server/global-config.ts +200 -0
- package/server/index.ts +975 -287
- package/server/init.ts +625 -182
- package/server/launch.ts +32 -0
- package/server/manifest-types.ts +145 -0
- package/server/manifest.ts +494 -0
- package/server/migrate-legacy.ts +290 -0
- package/server/parsers/event-parser.test.ts +135 -0
- package/server/parsers/scope-parser.test.ts +270 -0
- package/server/parsers/scope-parser.ts +79 -31
- package/server/project-context.ts +309 -0
- package/server/project-emitter.ts +50 -0
- package/server/project-manager.ts +369 -0
- package/server/routes/config-routes.ts +3 -5
- package/server/routes/data-routes.ts +28 -141
- package/server/routes/dispatch-routes.ts +19 -11
- package/server/routes/git-routes.ts +77 -0
- package/server/routes/manifest-routes.ts +388 -0
- package/server/routes/scope-routes.ts +29 -25
- package/server/routes/sync-routes.ts +175 -0
- package/server/routes/version-routes.ts +1 -16
- package/server/routes/workflow-routes.ts +9 -3
- package/server/schema.ts +2 -0
- package/server/services/batch-orchestrator.ts +24 -16
- package/server/services/claude-session-service.ts +16 -14
- package/server/services/deploy-service.test.ts +145 -0
- package/server/services/deploy-service.ts +2 -2
- package/server/services/event-service.test.ts +242 -0
- package/server/services/event-service.ts +92 -3
- package/server/services/gate-service.test.ts +131 -0
- package/server/services/gate-service.ts +2 -2
- package/server/services/git-service.ts +137 -4
- package/server/services/github-service.ts +120 -2
- package/server/services/readiness-service.test.ts +217 -0
- package/server/services/scope-cache.test.ts +167 -0
- package/server/services/scope-cache.ts +4 -1
- package/server/services/scope-service.test.ts +169 -0
- package/server/services/scope-service.ts +220 -126
- package/server/services/sprint-orchestrator.ts +7 -7
- package/server/services/sprint-service.test.ts +271 -0
- package/server/services/sprint-service.ts +27 -3
- package/server/services/sync-service.ts +482 -0
- package/server/services/sync-types.ts +77 -0
- package/server/services/telemetry-service.ts +195 -0
- package/server/services/workflow-service.test.ts +190 -0
- package/server/services/workflow-service.ts +29 -9
- package/server/settings-sync.ts +359 -0
- package/server/update-planner.ts +346 -0
- package/server/utils/cc-hooks-parser.test.ts +96 -0
- package/server/utils/cc-hooks-parser.ts +4 -0
- package/server/utils/dispatch-utils.test.ts +245 -0
- package/server/utils/dispatch-utils.ts +97 -27
- package/server/utils/logger.ts +40 -3
- package/server/utils/package-info.ts +32 -0
- package/server/utils/route-helpers.ts +12 -0
- package/server/utils/terminal-launcher.ts +85 -25
- package/server/utils/worktree-manager.ts +9 -4
- package/server/validator.ts +270 -0
- package/server/watchers/global-watcher.ts +77 -0
- package/server/watchers/scope-watcher.ts +21 -9
- package/server/wizard/config-editor.ts +248 -0
- package/server/wizard/detect.ts +104 -0
- package/server/wizard/doctor.ts +114 -0
- package/server/wizard/index.ts +187 -0
- package/server/wizard/phases/confirm.ts +45 -0
- package/server/wizard/phases/project-setup.ts +106 -0
- package/server/wizard/phases/setup-wizard.ts +78 -0
- package/server/wizard/phases/welcome.ts +43 -0
- package/server/wizard/phases/workflow-setup.ts +28 -0
- package/server/wizard/types.ts +56 -0
- package/server/wizard/ui.ts +93 -0
- package/shared/__fixtures__/workflow-configs.ts +80 -0
- package/shared/default-workflow.json +65 -0
- package/shared/onboarding-tour.test.ts +94 -0
- package/shared/project-colors.ts +24 -0
- package/shared/workflow-config.test.ts +111 -0
- package/shared/workflow-config.ts +7 -0
- package/shared/workflow-engine.test.ts +388 -0
- package/shared/workflow-normalizer.test.ts +119 -0
- package/shared/workflow-normalizer.ts +118 -0
- package/templates/hooks/end-session.sh +3 -1
- package/templates/hooks/orbital-emit.sh +2 -2
- package/templates/hooks/orbital-report-deploy.sh +4 -4
- package/templates/hooks/orbital-report-gates.sh +4 -4
- package/templates/hooks/orbital-scope-update.sh +1 -1
- package/templates/hooks/scope-create-cleanup.sh +2 -2
- package/templates/hooks/scope-create-gate.sh +0 -1
- package/templates/hooks/scope-helpers.sh +18 -0
- package/templates/hooks/scope-prepare.sh +66 -11
- package/templates/migrations/renames.json +1 -0
- package/templates/orbital.config.json +7 -2
- package/templates/settings-hooks.json +1 -1
- package/templates/skills/git-commit/SKILL.md +9 -4
- package/templates/skills/git-dev/SKILL.md +8 -3
- package/templates/skills/git-main/SKILL.md +8 -2
- package/templates/skills/git-production/SKILL.md +6 -2
- package/templates/skills/git-staging/SKILL.md +8 -3
- package/templates/skills/scope-create/SKILL.md +17 -3
- package/templates/skills/scope-fix-review/SKILL.md +6 -3
- package/templates/skills/scope-implement/SKILL.md +4 -1
- package/templates/skills/scope-post-review/SKILL.md +63 -5
- package/templates/skills/scope-pre-review/SKILL.md +5 -2
- package/templates/skills/scope-verify/SKILL.md +5 -3
- package/templates/skills/test-code-review/SKILL.md +41 -33
- package/templates/skills/test-scaffold/SKILL.md +222 -0
- package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +0 -84
- package/dist/assets/charts-D__PA1zp.js +0 -72
- package/dist/assets/index-D1G6i0nS.css +0 -1
- package/dist/assets/index-DpItvKpf.js +0 -419
- package/dist/assets/ui-BvF022GT.js +0 -53
- package/index.html +0 -15
- package/postcss.config.js +0 -6
- package/src/App.tsx +0 -33
- package/src/components/AgentBadge.tsx +0 -40
- package/src/components/BatchPreflightModal.tsx +0 -115
- package/src/components/CardDisplayToggle.tsx +0 -74
- package/src/components/ColumnHeaderActions.tsx +0 -55
- package/src/components/ColumnMenu.tsx +0 -99
- package/src/components/DeployHistory.tsx +0 -141
- package/src/components/DispatchModal.tsx +0 -164
- package/src/components/DispatchPopover.tsx +0 -139
- package/src/components/DragOverlay.tsx +0 -25
- package/src/components/DriftSidebar.tsx +0 -140
- package/src/components/EnvironmentStrip.tsx +0 -88
- package/src/components/ErrorBoundary.tsx +0 -62
- package/src/components/FilterChip.tsx +0 -105
- package/src/components/GateIndicator.tsx +0 -33
- package/src/components/IdeaDetailModal.tsx +0 -190
- package/src/components/IdeaFormDialog.tsx +0 -113
- package/src/components/KanbanColumn.tsx +0 -201
- package/src/components/MarkdownRenderer.tsx +0 -114
- package/src/components/NeonGrid.tsx +0 -128
- package/src/components/PromotionQueue.tsx +0 -89
- package/src/components/ScopeCard.tsx +0 -234
- package/src/components/ScopeDetailModal.tsx +0 -255
- package/src/components/ScopeFilterBar.tsx +0 -152
- package/src/components/SearchInput.tsx +0 -102
- package/src/components/SessionPanel.tsx +0 -335
- package/src/components/SprintContainer.tsx +0 -303
- package/src/components/SprintDependencyDialog.tsx +0 -78
- package/src/components/SprintPreflightModal.tsx +0 -138
- package/src/components/StatusBar.tsx +0 -168
- package/src/components/SwimCell.tsx +0 -67
- package/src/components/SwimLaneRow.tsx +0 -94
- package/src/components/SwimlaneBoardView.tsx +0 -108
- package/src/components/VersionBadge.tsx +0 -139
- package/src/components/ViewModeSelector.tsx +0 -114
- package/src/components/config/AgentChip.tsx +0 -53
- package/src/components/config/AgentCreateDialog.tsx +0 -321
- package/src/components/config/AgentEditor.tsx +0 -175
- package/src/components/config/DirectoryTree.tsx +0 -582
- package/src/components/config/FileEditor.tsx +0 -550
- package/src/components/config/HookChip.tsx +0 -50
- package/src/components/config/StageCard.tsx +0 -198
- package/src/components/config/TransitionZone.tsx +0 -173
- package/src/components/config/UnifiedWorkflowPipeline.tsx +0 -216
- package/src/components/config/WorkflowPipeline.tsx +0 -161
- package/src/components/source-control/BranchList.tsx +0 -93
- package/src/components/source-control/BranchPanel.tsx +0 -105
- package/src/components/source-control/CommitLog.tsx +0 -100
- package/src/components/source-control/CommitRow.tsx +0 -47
- package/src/components/source-control/GitHubPanel.tsx +0 -110
- package/src/components/source-control/GitHubSetupGuide.tsx +0 -52
- package/src/components/source-control/GitOverviewBar.tsx +0 -101
- package/src/components/source-control/PullRequestList.tsx +0 -69
- package/src/components/source-control/WorktreeList.tsx +0 -80
- package/src/components/ui/badge.tsx +0 -41
- package/src/components/ui/button.tsx +0 -55
- package/src/components/ui/card.tsx +0 -78
- package/src/components/ui/dialog.tsx +0 -94
- package/src/components/ui/popover.tsx +0 -33
- package/src/components/ui/scroll-area.tsx +0 -54
- package/src/components/ui/separator.tsx +0 -28
- package/src/components/ui/tabs.tsx +0 -52
- package/src/components/ui/toggle-switch.tsx +0 -35
- package/src/components/ui/tooltip.tsx +0 -27
- package/src/components/workflow/AddEdgeDialog.tsx +0 -217
- package/src/components/workflow/AddListDialog.tsx +0 -201
- package/src/components/workflow/ChecklistEditor.tsx +0 -239
- package/src/components/workflow/CommandPrefixManager.tsx +0 -118
- package/src/components/workflow/ConfigSettingsPanel.tsx +0 -189
- package/src/components/workflow/DirectionSelector.tsx +0 -133
- package/src/components/workflow/DispatchConfigPanel.tsx +0 -180
- package/src/components/workflow/EdgeDetailPanel.tsx +0 -236
- package/src/components/workflow/EdgePropertyEditor.tsx +0 -251
- package/src/components/workflow/EditToolbar.tsx +0 -138
- package/src/components/workflow/HookDetailPanel.tsx +0 -250
- package/src/components/workflow/HookExecutionLog.tsx +0 -24
- package/src/components/workflow/HookSourceModal.tsx +0 -129
- package/src/components/workflow/HooksDashboard.tsx +0 -363
- package/src/components/workflow/ListPropertyEditor.tsx +0 -251
- package/src/components/workflow/MigrationPreviewDialog.tsx +0 -237
- package/src/components/workflow/MovementRulesPanel.tsx +0 -188
- package/src/components/workflow/NodeDetailPanel.tsx +0 -245
- package/src/components/workflow/PresetSelector.tsx +0 -414
- package/src/components/workflow/SkillCommandBuilder.tsx +0 -174
- package/src/components/workflow/WorkflowEdgeComponent.tsx +0 -145
- package/src/components/workflow/WorkflowNode.tsx +0 -147
- package/src/components/workflow/graphLayout.ts +0 -186
- package/src/components/workflow/mergeHooks.ts +0 -85
- package/src/components/workflow/useEditHistory.ts +0 -88
- package/src/components/workflow/useWorkflowEditor.ts +0 -262
- package/src/components/workflow/validateConfig.ts +0 -70
- package/src/hooks/useActiveDispatches.ts +0 -198
- package/src/hooks/useBoardSettings.ts +0 -170
- package/src/hooks/useCardDisplay.ts +0 -57
- package/src/hooks/useCcHooks.ts +0 -24
- package/src/hooks/useConfigTree.ts +0 -51
- package/src/hooks/useEnforcementRules.ts +0 -46
- package/src/hooks/useEvents.ts +0 -59
- package/src/hooks/useFileEditor.ts +0 -165
- package/src/hooks/useGates.ts +0 -57
- package/src/hooks/useIdeaActions.ts +0 -53
- package/src/hooks/useKanbanDnd.ts +0 -410
- package/src/hooks/useOrbitalConfig.ts +0 -54
- package/src/hooks/usePipeline.ts +0 -47
- package/src/hooks/usePipelineData.ts +0 -338
- package/src/hooks/useReconnect.ts +0 -25
- package/src/hooks/useScopeFilters.ts +0 -125
- package/src/hooks/useScopeSessions.ts +0 -44
- package/src/hooks/useScopes.ts +0 -67
- package/src/hooks/useSearch.ts +0 -67
- package/src/hooks/useSettings.tsx +0 -187
- package/src/hooks/useSocket.ts +0 -25
- package/src/hooks/useSourceControl.ts +0 -105
- package/src/hooks/useSprintPreflight.ts +0 -55
- package/src/hooks/useSprints.ts +0 -154
- package/src/hooks/useStatusBarHighlight.ts +0 -18
- package/src/hooks/useSwimlaneBoardSettings.ts +0 -104
- package/src/hooks/useTheme.ts +0 -9
- package/src/hooks/useTransitionReadiness.ts +0 -53
- package/src/hooks/useVersion.ts +0 -155
- package/src/hooks/useViolations.ts +0 -65
- package/src/hooks/useWorkflow.tsx +0 -125
- package/src/hooks/useZoomModifier.ts +0 -19
- package/src/index.css +0 -797
- package/src/layouts/DashboardLayout.tsx +0 -113
- package/src/lib/collisionDetection.ts +0 -20
- package/src/lib/scope-fields.ts +0 -61
- package/src/lib/swimlane.ts +0 -146
- package/src/lib/utils.ts +0 -15
- package/src/main.tsx +0 -19
- package/src/socket.ts +0 -11
- package/src/types/index.ts +0 -497
- package/src/views/AgentFeed.tsx +0 -339
- package/src/views/DeployPipeline.tsx +0 -59
- package/src/views/EnforcementView.tsx +0 -378
- package/src/views/PrimitivesConfig.tsx +0 -500
- package/src/views/QualityGates.tsx +0 -1012
- package/src/views/ScopeBoard.tsx +0 -454
- package/src/views/SessionTimeline.tsx +0 -516
- package/src/views/Settings.tsx +0 -183
- package/src/views/SourceControl.tsx +0 -95
- package/src/views/WorkflowVisualizer.tsx +0 -382
- package/tailwind.config.js +0 -161
- package/tsconfig.json +0 -25
- package/vite.config.ts +0 -49
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFile as execFileCb } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import { listWorktrees } from '../utils/worktree-manager.js';
|
|
4
|
+
import { createLogger } from '../utils/logger.js';
|
|
4
5
|
const execFile = promisify(execFileCb);
|
|
6
|
+
const log = createLogger('git');
|
|
5
7
|
const CACHE_TTL = 60_000; // 60 seconds
|
|
6
8
|
function cached(cache, key) {
|
|
7
9
|
const entry = cache.get(key);
|
|
@@ -115,7 +117,8 @@ export class GitService {
|
|
|
115
117
|
try {
|
|
116
118
|
raw = await this.git(args);
|
|
117
119
|
}
|
|
118
|
-
catch {
|
|
120
|
+
catch (err) {
|
|
121
|
+
log.debug('Commits query failed', { branch: branch ?? 'all', error: String(err) });
|
|
119
122
|
return [];
|
|
120
123
|
}
|
|
121
124
|
const commits = [];
|
|
@@ -172,7 +175,8 @@ export class GitService {
|
|
|
172
175
|
'--format=%(HEAD)|%(refname:short)|%(objectname:short)|%(committerdate:iso-strict)|%(subject)',
|
|
173
176
|
]);
|
|
174
177
|
}
|
|
175
|
-
catch {
|
|
178
|
+
catch (err) {
|
|
179
|
+
log.debug('Branch listing failed', { error: String(err) });
|
|
176
180
|
return [];
|
|
177
181
|
}
|
|
178
182
|
const now = Date.now();
|
|
@@ -230,7 +234,8 @@ export class GitService {
|
|
|
230
234
|
try {
|
|
231
235
|
wts = await listWorktrees(this.projectRoot);
|
|
232
236
|
}
|
|
233
|
-
catch {
|
|
237
|
+
catch (err) {
|
|
238
|
+
log.warn('Failed to list worktrees', { error: String(err) });
|
|
234
239
|
return [];
|
|
235
240
|
}
|
|
236
241
|
const results = [];
|
|
@@ -288,13 +293,76 @@ export class GitService {
|
|
|
288
293
|
});
|
|
289
294
|
pairs.push({ from, to, count: commits.length, commits });
|
|
290
295
|
}
|
|
291
|
-
catch {
|
|
296
|
+
catch (err) {
|
|
297
|
+
log.debug('Drift query failed', { from, to, error: String(err) });
|
|
292
298
|
pairs.push({ from, to, count: 0, commits: [] });
|
|
293
299
|
}
|
|
294
300
|
}
|
|
295
301
|
setCache(this.cache, cacheKey, pairs);
|
|
296
302
|
return pairs;
|
|
297
303
|
}
|
|
304
|
+
// ─── Activity Series ────────────────────────────────────────
|
|
305
|
+
async getActivitySeries(days = 30) {
|
|
306
|
+
const cacheKey = `activity:${days}`;
|
|
307
|
+
const hit = cached(this.cache, cacheKey);
|
|
308
|
+
if (hit)
|
|
309
|
+
return hit;
|
|
310
|
+
try {
|
|
311
|
+
const raw = await this.git(['log', `--since=${days}.days.ago`, '--format=%aI', '--all']);
|
|
312
|
+
const counts = new Map();
|
|
313
|
+
// Initialize all days to 0
|
|
314
|
+
const now = new Date();
|
|
315
|
+
for (let i = 0; i < days; i++) {
|
|
316
|
+
const d = new Date(now);
|
|
317
|
+
d.setDate(d.getDate() - i);
|
|
318
|
+
counts.set(d.toISOString().slice(0, 10), 0);
|
|
319
|
+
}
|
|
320
|
+
for (const line of raw.trim().split('\n')) {
|
|
321
|
+
if (!line)
|
|
322
|
+
continue;
|
|
323
|
+
const dateKey = line.slice(0, 10);
|
|
324
|
+
counts.set(dateKey, (counts.get(dateKey) ?? 0) + 1);
|
|
325
|
+
}
|
|
326
|
+
const series = [...counts.entries()]
|
|
327
|
+
.map(([date, count]) => ({ date, count }))
|
|
328
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
329
|
+
setCache(this.cache, cacheKey, series);
|
|
330
|
+
return series;
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
log.debug('Activity series query failed', { days, error: String(err) });
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// ─── Health Metrics ────────────────────────────────────────
|
|
338
|
+
async getHealthMetrics(githubPrAges) {
|
|
339
|
+
// Commits per week
|
|
340
|
+
let commitsPerWeek = 0;
|
|
341
|
+
try {
|
|
342
|
+
const raw = await this.git(['log', '--since=7.days.ago', '--oneline', '--all']);
|
|
343
|
+
commitsPerWeek = raw.trim().split('\n').filter(Boolean).length;
|
|
344
|
+
}
|
|
345
|
+
catch { /* ok */ }
|
|
346
|
+
// Stale branches
|
|
347
|
+
const branches = await this.getBranches();
|
|
348
|
+
const staleBranchCount = branches.filter(b => b.isStale && !b.isRemote).length;
|
|
349
|
+
// Avg PR age
|
|
350
|
+
let avgPrAgeDays = 0;
|
|
351
|
+
if (githubPrAges && githubPrAges.length > 0) {
|
|
352
|
+
avgPrAgeDays = Math.round(githubPrAges.reduce((a, b) => a + b, 0) / githubPrAges.length);
|
|
353
|
+
}
|
|
354
|
+
// Drift severity (reuse cached data if available)
|
|
355
|
+
const driftSeverity = 'clean';
|
|
356
|
+
// Grade computation: start at 100
|
|
357
|
+
let score = 100;
|
|
358
|
+
score -= Math.min(30, staleBranchCount * 5);
|
|
359
|
+
if (commitsPerWeek < 5)
|
|
360
|
+
score -= 10;
|
|
361
|
+
if (avgPrAgeDays > 3)
|
|
362
|
+
score -= Math.min(25, (avgPrAgeDays - 3) * 5);
|
|
363
|
+
const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
|
|
364
|
+
return { commitsPerWeek, avgPrAgeDays, staleBranchCount, driftSeverity, grade };
|
|
365
|
+
}
|
|
298
366
|
// ─── Git Status Polling ────────────────────────────────────
|
|
299
367
|
async getStatusHash() {
|
|
300
368
|
const [head, dirty] = await Promise.all([
|
|
@@ -303,6 +371,42 @@ export class GitService {
|
|
|
303
371
|
]);
|
|
304
372
|
return `${head.trim()}:${dirty.trim().length > 0 ? 'dirty' : 'clean'}`;
|
|
305
373
|
}
|
|
374
|
+
// ─── Pipeline Drift ─────────────────────────────────────────
|
|
375
|
+
async getPipelineDrift() {
|
|
376
|
+
const cacheKey = 'pipeline-drift';
|
|
377
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
378
|
+
const hit = cached(this.cache, cacheKey);
|
|
379
|
+
if (hit)
|
|
380
|
+
return hit;
|
|
381
|
+
function parseDriftCommits(raw) {
|
|
382
|
+
if (!raw)
|
|
383
|
+
return [];
|
|
384
|
+
return raw.split('\n').map((line) => {
|
|
385
|
+
const [sha, date, message, author] = line.split('|');
|
|
386
|
+
return { sha, date, message: message ?? '', author: author ?? '' };
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function parseHead(raw) {
|
|
390
|
+
const [sha, date, message] = raw.split('|');
|
|
391
|
+
return { sha: sha ?? '', date: date ?? '', message: message ?? '' };
|
|
392
|
+
}
|
|
393
|
+
const [devToStagingRaw, stagingToMainRaw, devHead, stagingHead, mainHead] = await Promise.all([
|
|
394
|
+
this.git(['log', 'origin/dev', '--not', 'origin/staging', '--reverse', '--format=%H|%aI|%s|%an']).catch(() => ''),
|
|
395
|
+
this.git(['log', 'origin/staging', '--not', 'origin/main', '--reverse', '--format=%H|%aI|%s|%an']).catch(() => ''),
|
|
396
|
+
this.git(['log', 'origin/dev', '-1', '--format=%H|%aI|%s']).catch(() => ''),
|
|
397
|
+
this.git(['log', 'origin/staging', '-1', '--format=%H|%aI|%s']).catch(() => ''),
|
|
398
|
+
this.git(['log', 'origin/main', '-1', '--format=%H|%aI|%s']).catch(() => ''),
|
|
399
|
+
]);
|
|
400
|
+
const devToStaging = parseDriftCommits(devToStagingRaw);
|
|
401
|
+
const stagingToMain = parseDriftCommits(stagingToMainRaw);
|
|
402
|
+
const data = {
|
|
403
|
+
devToStaging: { count: devToStaging.length, commits: devToStaging, oldestDate: devToStaging[0]?.date ?? null },
|
|
404
|
+
stagingToMain: { count: stagingToMain.length, commits: stagingToMain, oldestDate: stagingToMain[0]?.date ?? null },
|
|
405
|
+
heads: { dev: parseHead(devHead), staging: parseHead(stagingHead), main: parseHead(mainHead) },
|
|
406
|
+
};
|
|
407
|
+
setCache(this.cache, cacheKey, data);
|
|
408
|
+
return data;
|
|
409
|
+
}
|
|
306
410
|
clearCache() {
|
|
307
411
|
this.cache.clear();
|
|
308
412
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile as execFileCb } from 'child_process';
|
|
1
|
+
import { execFile as execFileCb, spawn } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
// Uses execFile (not exec) — safe against shell injection
|
|
4
4
|
const execFile = promisify(execFileCb);
|
|
@@ -107,7 +107,7 @@ export class GitHubService {
|
|
|
107
107
|
try {
|
|
108
108
|
const raw = await this.gh([
|
|
109
109
|
'pr', 'list', '--state', 'open', '--json',
|
|
110
|
-
'number,title,author,headRefName,baseRefName,state,url,createdAt',
|
|
110
|
+
'number,title,author,headRefName,baseRefName,state,url,createdAt,updatedAt,reviewDecision',
|
|
111
111
|
'--limit', '30',
|
|
112
112
|
]);
|
|
113
113
|
const parsed = JSON.parse(raw);
|
|
@@ -133,6 +133,8 @@ export class GitHubService {
|
|
|
133
133
|
url: String(pr.url ?? ''),
|
|
134
134
|
createdAt: String(pr.createdAt ?? ''),
|
|
135
135
|
scopeIds,
|
|
136
|
+
reviewDecision: pr.reviewDecision || null,
|
|
137
|
+
lastActivityAt: String(pr.updatedAt ?? pr.createdAt ?? ''),
|
|
136
138
|
};
|
|
137
139
|
});
|
|
138
140
|
this.prCache = { data: prs, ts: Date.now() };
|
|
@@ -142,4 +144,110 @@ export class GitHubService {
|
|
|
142
144
|
return [];
|
|
143
145
|
}
|
|
144
146
|
}
|
|
147
|
+
// ─── Auth Flow ─────────────────────────────────────────────
|
|
148
|
+
/** Start OAuth flow via gh CLI — opens browser. Client polls getAuthStatus(). */
|
|
149
|
+
async connectOAuth() {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
// spawn is safe here: no shell, args are hardcoded literals
|
|
152
|
+
const child = spawn('gh', ['auth', 'login', '--web', '--git-protocol', 'https'], {
|
|
153
|
+
cwd: this.projectRoot,
|
|
154
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
155
|
+
});
|
|
156
|
+
let stderr = '';
|
|
157
|
+
child.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
158
|
+
// Resolve quickly — the gh process runs in background, client polls auth status
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
resolve({ success: true });
|
|
161
|
+
}, 500);
|
|
162
|
+
child.on('error', () => {
|
|
163
|
+
resolve({ success: false, error: stderr || 'Failed to start auth flow' });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** Authenticate using a Personal Access Token piped to gh stdin. */
|
|
168
|
+
async connectWithToken(token) {
|
|
169
|
+
try {
|
|
170
|
+
// spawn is safe: no shell, args are hardcoded literals, token via stdin (not args)
|
|
171
|
+
const child = spawn('gh', ['auth', 'login', '--with-token'], {
|
|
172
|
+
cwd: this.projectRoot,
|
|
173
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
174
|
+
});
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
let stderr = '';
|
|
177
|
+
child.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
178
|
+
child.on('close', (code) => {
|
|
179
|
+
this.clearCaches();
|
|
180
|
+
if (code === 0) {
|
|
181
|
+
resolve({ success: true });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
resolve({ success: false, error: stderr || 'Authentication failed' });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
child.on('error', () => {
|
|
188
|
+
resolve({ success: false, error: 'Failed to run gh auth' });
|
|
189
|
+
});
|
|
190
|
+
child.stdin?.write(token);
|
|
191
|
+
child.stdin?.end();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { success: false, error: 'Failed to authenticate' };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Lightweight auth check — returns current user if authenticated. */
|
|
199
|
+
async getAuthStatus() {
|
|
200
|
+
try {
|
|
201
|
+
const whoami = await this.gh(['api', 'user', '--jq', '.login']);
|
|
202
|
+
const user = whoami.trim();
|
|
203
|
+
return user ? { authenticated: true, user } : { authenticated: false };
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return { authenticated: false };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Log out of GitHub CLI. */
|
|
210
|
+
async disconnect() {
|
|
211
|
+
try {
|
|
212
|
+
// execFile is safe — no shell injection
|
|
213
|
+
await execFile('gh', ['auth', 'logout', '--hostname', 'github.com'], {
|
|
214
|
+
cwd: this.projectRoot,
|
|
215
|
+
timeout: 10_000,
|
|
216
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1' },
|
|
217
|
+
});
|
|
218
|
+
this.clearCaches();
|
|
219
|
+
return { success: true };
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
this.clearCaches();
|
|
223
|
+
return { success: false, error: String(err) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ─── CI Checks ─────────────────────────────────────────────
|
|
227
|
+
/** Fetch GitHub Actions check runs for a commit ref. */
|
|
228
|
+
async getCheckRuns(ref) {
|
|
229
|
+
// Validate ref to prevent path traversal — only allow hex SHA and branch-like names
|
|
230
|
+
if (!/^[a-zA-Z0-9._/-]+$/.test(ref))
|
|
231
|
+
return [];
|
|
232
|
+
try {
|
|
233
|
+
const raw = await this.gh([
|
|
234
|
+
'api', `repos/{owner}/{repo}/commits/${ref}/check-runs`,
|
|
235
|
+
'--jq', '.check_runs | map({name, status, conclusion, html_url})',
|
|
236
|
+
]);
|
|
237
|
+
const parsed = JSON.parse(raw);
|
|
238
|
+
return parsed.map(c => ({
|
|
239
|
+
name: String(c.name ?? ''),
|
|
240
|
+
status: String(c.status ?? 'queued'),
|
|
241
|
+
conclusion: c.conclusion ? String(c.conclusion) : null,
|
|
242
|
+
url: String(c.html_url ?? ''),
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
clearCaches() {
|
|
250
|
+
this.statusCache = null;
|
|
251
|
+
this.prCache = null;
|
|
252
|
+
}
|
|
145
253
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { ReadinessService } from './readiness-service.js';
|
|
3
|
+
import { WorkflowEngine } from '../../shared/workflow-engine.js';
|
|
4
|
+
import { CONFIG_WITH_HOOKS } from '../../shared/__fixtures__/workflow-configs.js';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
function makeScope(overrides) {
|
|
9
|
+
return {
|
|
10
|
+
title: `Scope ${overrides.id}`,
|
|
11
|
+
slug: undefined,
|
|
12
|
+
status: 'backlog',
|
|
13
|
+
priority: null,
|
|
14
|
+
effort_estimate: null,
|
|
15
|
+
category: null,
|
|
16
|
+
tags: [],
|
|
17
|
+
blocked_by: [],
|
|
18
|
+
blocks: [],
|
|
19
|
+
file_path: `/scopes/backlog/${String(overrides.id).padStart(3, '0')}-test.md`,
|
|
20
|
+
created_at: null,
|
|
21
|
+
updated_at: null,
|
|
22
|
+
raw_content: '# Test Scope\nSome content here.',
|
|
23
|
+
sessions: {},
|
|
24
|
+
is_ghost: false,
|
|
25
|
+
favourite: false,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('ReadinessService', () => {
|
|
30
|
+
let engine;
|
|
31
|
+
let tmpDir;
|
|
32
|
+
let service;
|
|
33
|
+
let mockScopeService;
|
|
34
|
+
let mockGateService;
|
|
35
|
+
let scopes;
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
engine = new WorkflowEngine(CONFIG_WITH_HOOKS);
|
|
38
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readiness-test-'));
|
|
39
|
+
scopes = [
|
|
40
|
+
makeScope({ id: 1, status: 'backlog', sessions: { implementScope: ['sess-1'] } }),
|
|
41
|
+
makeScope({ id: 2, status: 'active', sessions: { implementScope: ['sess-1'] }, blocked_by: [99] }),
|
|
42
|
+
makeScope({ id: 3, status: 'active', sessions: { implementScope: ['sess-1'] }, raw_content: '# Scope\n- [ ] Task 1\n- [x] Task 2' }),
|
|
43
|
+
makeScope({ id: 99, status: 'shipped' }), // terminal blocker
|
|
44
|
+
makeScope({ id: 100, status: 'backlog' }), // non-terminal blocker
|
|
45
|
+
];
|
|
46
|
+
mockScopeService = { getById: (id) => scopes.find(s => s.id === id) };
|
|
47
|
+
mockGateService = {
|
|
48
|
+
getLatestForScope: vi.fn().mockReturnValue([]),
|
|
49
|
+
getLatestRun: vi.fn().mockReturnValue([]),
|
|
50
|
+
};
|
|
51
|
+
service = new ReadinessService(mockScopeService, mockGateService, engine, tmpDir);
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
describe('getReadiness()', () => {
|
|
57
|
+
it('returns null for unknown scope', () => {
|
|
58
|
+
expect(service.getReadiness(999)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
it('returns readiness with transitions for known scope', () => {
|
|
61
|
+
const result = service.getReadiness(1);
|
|
62
|
+
expect(result).not.toBeNull();
|
|
63
|
+
expect(result.scope_id).toBe(1);
|
|
64
|
+
expect(result.transitions).toBeDefined();
|
|
65
|
+
expect(result.transitions.length).toBeGreaterThan(0);
|
|
66
|
+
});
|
|
67
|
+
it('includes hook statuses for each transition', () => {
|
|
68
|
+
const result = service.getReadiness(1);
|
|
69
|
+
const transition = result.transitions[0];
|
|
70
|
+
expect(transition.hooks).toBeDefined();
|
|
71
|
+
expect(Array.isArray(transition.hooks)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('session-enforcer hook', () => {
|
|
75
|
+
it('passes when session key exists for target column', () => {
|
|
76
|
+
// Scope 1 is in backlog, transition to active requires implementScope session key
|
|
77
|
+
// Scope 1 has sessions.implementScope = ['sess-1']
|
|
78
|
+
const result = service.getReadiness(1);
|
|
79
|
+
const toActive = result.transitions.find(t => t.to === 'active');
|
|
80
|
+
if (toActive) {
|
|
81
|
+
const enforcer = toActive.hooks.find(h => h.id === 'session-enforcer');
|
|
82
|
+
// If session-enforcer is on this edge, it should look at the target session key
|
|
83
|
+
if (enforcer) {
|
|
84
|
+
// The scope has the required session, so it should pass
|
|
85
|
+
expect(['pass', 'unknown']).toContain(enforcer.status);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('blocker-check hook', () => {
|
|
91
|
+
it('passes when blocker is in terminal status', () => {
|
|
92
|
+
// Scope 2 blocked_by [99], scope 99 is shipped (terminal)
|
|
93
|
+
const result = service.getReadiness(2);
|
|
94
|
+
const transition = result.transitions.find(t => t.hooks.some(h => h.id === 'blocker-check'));
|
|
95
|
+
if (transition) {
|
|
96
|
+
const blockerHook = transition.hooks.find(h => h.id === 'blocker-check');
|
|
97
|
+
expect(blockerHook?.status).toBe('pass');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('fails when blocker is not in terminal status', () => {
|
|
101
|
+
// Add a scope blocked by non-terminal scope
|
|
102
|
+
scopes.push(makeScope({ id: 5, status: 'active', blocked_by: [100], sessions: { implementScope: ['s'] } }));
|
|
103
|
+
const result = service.getReadiness(5);
|
|
104
|
+
const transition = result.transitions.find(t => t.hooks.some(h => h.id === 'blocker-check'));
|
|
105
|
+
if (transition) {
|
|
106
|
+
const blockerHook = transition.hooks.find(h => h.id === 'blocker-check');
|
|
107
|
+
expect(blockerHook?.status).toBe('fail');
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('review-gate-check hook', () => {
|
|
112
|
+
it('passes when verdict file exists with PASS', () => {
|
|
113
|
+
const verdictDir = path.join(tmpDir, '.claude', 'review-verdicts');
|
|
114
|
+
fs.mkdirSync(verdictDir, { recursive: true });
|
|
115
|
+
fs.writeFileSync(path.join(verdictDir, '002.json'), JSON.stringify({ verdict: 'PASS' }));
|
|
116
|
+
// Scope 2 is in active, edge active→review has review-gate-check
|
|
117
|
+
const result = service.getReadiness(2);
|
|
118
|
+
const toReview = result.transitions.find(t => t.to === 'review');
|
|
119
|
+
if (toReview) {
|
|
120
|
+
const reviewHook = toReview.hooks.find(h => h.id === 'review-gate-check');
|
|
121
|
+
if (reviewHook) {
|
|
122
|
+
expect(reviewHook.status).toBe('pass');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
it('fails when verdict file is missing', () => {
|
|
127
|
+
const result = service.getReadiness(2);
|
|
128
|
+
const toReview = result.transitions.find(t => t.to === 'review');
|
|
129
|
+
if (toReview) {
|
|
130
|
+
const reviewHook = toReview.hooks.find(h => h.id === 'review-gate-check');
|
|
131
|
+
if (reviewHook) {
|
|
132
|
+
expect(reviewHook.status).toBe('fail');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('completion-checklist hook', () => {
|
|
138
|
+
it('fails when scope has unchecked items', () => {
|
|
139
|
+
// Scope 3 has raw_content with "- [ ] Task 1" (unchecked)
|
|
140
|
+
const result = service.getReadiness(3);
|
|
141
|
+
for (const transition of result.transitions) {
|
|
142
|
+
const checklist = transition.hooks.find(h => h.id === 'completion-checklist');
|
|
143
|
+
if (checklist) {
|
|
144
|
+
expect(checklist.status).toBe('fail');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('scope-create-gate hook', () => {
|
|
150
|
+
it('passes when scope has title and content', () => {
|
|
151
|
+
const result = service.getReadiness(1);
|
|
152
|
+
for (const transition of result.transitions) {
|
|
153
|
+
const gate = transition.hooks.find(h => h.id === 'scope-create-gate');
|
|
154
|
+
if (gate) {
|
|
155
|
+
expect(gate.status).toBe('pass');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
it('fails when scope has no content', () => {
|
|
160
|
+
scopes.push(makeScope({ id: 6, status: 'backlog', title: '', raw_content: '' }));
|
|
161
|
+
const result = service.getReadiness(6);
|
|
162
|
+
for (const transition of result.transitions) {
|
|
163
|
+
const gate = transition.hooks.find(h => h.id === 'scope-create-gate');
|
|
164
|
+
if (gate) {
|
|
165
|
+
expect(gate.status).toBe('fail');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('lifecycle/observer hooks', () => {
|
|
171
|
+
it('scope-transition always passes', () => {
|
|
172
|
+
const result = service.getReadiness(1);
|
|
173
|
+
for (const transition of result.transitions) {
|
|
174
|
+
const lifecycle = transition.hooks.find(h => h.id === 'scope-transition');
|
|
175
|
+
if (lifecycle) {
|
|
176
|
+
expect(lifecycle.status).toBe('pass');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
it('dashboard-sync always passes', () => {
|
|
181
|
+
const result = service.getReadiness(1);
|
|
182
|
+
for (const transition of result.transitions) {
|
|
183
|
+
const observer = transition.hooks.find(h => h.id === 'dashboard-sync');
|
|
184
|
+
if (observer) {
|
|
185
|
+
expect(observer.status).toBe('pass');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -52,7 +52,8 @@ export class ScopeCache {
|
|
|
52
52
|
}
|
|
53
53
|
/** Get the maximum raw scope number excluding icebox scopes (for next-ID generation).
|
|
54
54
|
* Cache keys use encoded IDs (suffixed scopes like 047a → 1047, 075x → 9075),
|
|
55
|
-
* but next-ID generation needs the raw scope number (047, 075, 087).
|
|
55
|
+
* but next-ID generation needs the raw scope number (047, 075, 087).
|
|
56
|
+
* Skips IDs >= 500 to handle legacy icebox-origin files during migration. */
|
|
56
57
|
maxNonIceboxId() {
|
|
57
58
|
let max = 0;
|
|
58
59
|
for (const [id, scope] of this.byId) {
|
|
@@ -60,6 +61,9 @@ export class ScopeCache {
|
|
|
60
61
|
continue;
|
|
61
62
|
// Decode: encoded IDs ≥1000 have a suffix offset — raw number is id % 1000
|
|
62
63
|
const raw = id >= 1000 ? id % 1000 : id;
|
|
64
|
+
// Skip legacy icebox-origin IDs (500+) to prevent namespace pollution
|
|
65
|
+
if (raw >= 500)
|
|
66
|
+
continue;
|
|
63
67
|
if (raw > max)
|
|
64
68
|
max = raw;
|
|
65
69
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ScopeCache } from './scope-cache.js';
|
|
3
|
+
function makeScope(overrides) {
|
|
4
|
+
return {
|
|
5
|
+
title: `Scope ${overrides.id}`,
|
|
6
|
+
slug: undefined,
|
|
7
|
+
status: 'backlog',
|
|
8
|
+
priority: null,
|
|
9
|
+
effort_estimate: null,
|
|
10
|
+
category: null,
|
|
11
|
+
tags: [],
|
|
12
|
+
blocked_by: [],
|
|
13
|
+
blocks: [],
|
|
14
|
+
file_path: `/scopes/backlog/${String(overrides.id).padStart(3, '0')}-test.md`,
|
|
15
|
+
created_at: null,
|
|
16
|
+
updated_at: null,
|
|
17
|
+
raw_content: '',
|
|
18
|
+
sessions: {},
|
|
19
|
+
is_ghost: false,
|
|
20
|
+
favourite: false,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
describe('ScopeCache', () => {
|
|
25
|
+
let cache;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
cache = new ScopeCache();
|
|
28
|
+
});
|
|
29
|
+
describe('loadAll()', () => {
|
|
30
|
+
it('populates both indexes', () => {
|
|
31
|
+
const scopes = [makeScope({ id: 1 }), makeScope({ id: 2 })];
|
|
32
|
+
cache.loadAll(scopes);
|
|
33
|
+
expect(cache.size).toBe(2);
|
|
34
|
+
expect(cache.getById(1)).toBeDefined();
|
|
35
|
+
expect(cache.getById(2)).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
it('clears previous data on re-load', () => {
|
|
38
|
+
cache.loadAll([makeScope({ id: 1 })]);
|
|
39
|
+
expect(cache.size).toBe(1);
|
|
40
|
+
cache.loadAll([makeScope({ id: 5 }), makeScope({ id: 6 })]);
|
|
41
|
+
expect(cache.size).toBe(2);
|
|
42
|
+
expect(cache.has(1)).toBe(false);
|
|
43
|
+
expect(cache.has(5)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('set()', () => {
|
|
47
|
+
it('adds new scope', () => {
|
|
48
|
+
cache.set(makeScope({ id: 10 }));
|
|
49
|
+
expect(cache.has(10)).toBe(true);
|
|
50
|
+
expect(cache.size).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
it('updates existing scope', () => {
|
|
53
|
+
cache.set(makeScope({ id: 10, title: 'Original' }));
|
|
54
|
+
cache.set(makeScope({ id: 10, title: 'Updated' }));
|
|
55
|
+
expect(cache.getById(10)?.title).toBe('Updated');
|
|
56
|
+
expect(cache.size).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
it('cleans up old file_path index when scope moves', () => {
|
|
59
|
+
const oldPath = '/scopes/backlog/010-test.md';
|
|
60
|
+
const newPath = '/scopes/implementing/010-test.md';
|
|
61
|
+
cache.set(makeScope({ id: 10, file_path: oldPath }));
|
|
62
|
+
expect(cache.idByFilePath(oldPath)).toBe(10);
|
|
63
|
+
cache.set(makeScope({ id: 10, file_path: newPath }));
|
|
64
|
+
expect(cache.idByFilePath(oldPath)).toBeUndefined();
|
|
65
|
+
expect(cache.idByFilePath(newPath)).toBe(10);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('removeByFilePath()', () => {
|
|
69
|
+
it('removes from both indexes and returns removed ID', () => {
|
|
70
|
+
const scope = makeScope({ id: 10 });
|
|
71
|
+
cache.set(scope);
|
|
72
|
+
const removedId = cache.removeByFilePath(scope.file_path);
|
|
73
|
+
expect(removedId).toBe(10);
|
|
74
|
+
expect(cache.has(10)).toBe(false);
|
|
75
|
+
expect(cache.idByFilePath(scope.file_path)).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
it('returns undefined for unknown path', () => {
|
|
78
|
+
expect(cache.removeByFilePath('/nonexistent')).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('read operations', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
cache.loadAll([
|
|
84
|
+
makeScope({ id: 3, title: 'Three' }),
|
|
85
|
+
makeScope({ id: 1, title: 'One' }),
|
|
86
|
+
makeScope({ id: 2, title: 'Two' }),
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
it('getById() returns scope or undefined', () => {
|
|
90
|
+
expect(cache.getById(1)?.title).toBe('One');
|
|
91
|
+
expect(cache.getById(999)).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
it('getAll() returns sorted by ID', () => {
|
|
94
|
+
const all = cache.getAll();
|
|
95
|
+
expect(all.map(s => s.id)).toEqual([1, 2, 3]);
|
|
96
|
+
});
|
|
97
|
+
it('has() returns boolean', () => {
|
|
98
|
+
expect(cache.has(1)).toBe(true);
|
|
99
|
+
expect(cache.has(999)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it('idByFilePath() returns ID or undefined', () => {
|
|
102
|
+
const scope = cache.getById(1);
|
|
103
|
+
expect(cache.idByFilePath(scope.file_path)).toBe(1);
|
|
104
|
+
expect(cache.idByFilePath('/nonexistent')).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('maxNonIceboxId()', () => {
|
|
108
|
+
it('returns 0 for empty cache', () => {
|
|
109
|
+
expect(cache.maxNonIceboxId()).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
it('returns highest raw ID', () => {
|
|
112
|
+
cache.loadAll([
|
|
113
|
+
makeScope({ id: 10, status: 'backlog' }),
|
|
114
|
+
makeScope({ id: 20, status: 'implementing' }),
|
|
115
|
+
makeScope({ id: 5, status: 'review' }),
|
|
116
|
+
]);
|
|
117
|
+
expect(cache.maxNonIceboxId()).toBe(20);
|
|
118
|
+
});
|
|
119
|
+
it('ignores icebox-status scopes', () => {
|
|
120
|
+
cache.loadAll([
|
|
121
|
+
makeScope({ id: 10, status: 'backlog' }),
|
|
122
|
+
makeScope({ id: 50, status: 'icebox' }),
|
|
123
|
+
]);
|
|
124
|
+
expect(cache.maxNonIceboxId()).toBe(10);
|
|
125
|
+
});
|
|
126
|
+
it('decodes encoded IDs (>= 1000) to raw numbers', () => {
|
|
127
|
+
// 1047 → suffix-encoded → raw is 1047 % 1000 = 47
|
|
128
|
+
cache.loadAll([
|
|
129
|
+
makeScope({ id: 1047, status: 'backlog' }),
|
|
130
|
+
makeScope({ id: 10, status: 'implementing' }),
|
|
131
|
+
]);
|
|
132
|
+
expect(cache.maxNonIceboxId()).toBe(47);
|
|
133
|
+
});
|
|
134
|
+
it('skips raw IDs >= 500 (legacy icebox-origin)', () => {
|
|
135
|
+
cache.loadAll([
|
|
136
|
+
makeScope({ id: 501, status: 'backlog' }),
|
|
137
|
+
makeScope({ id: 30, status: 'implementing' }),
|
|
138
|
+
]);
|
|
139
|
+
expect(cache.maxNonIceboxId()).toBe(30);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|