@dsdenes/devboard 0.1.559 → 0.1.562
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/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Server } from "bun";
|
|
5
|
+
import type { Exec } from "../lib/forge-auth.ts";
|
|
6
|
+
import { createApp } from "./app";
|
|
7
|
+
import { CommandFlowForge, type ForgeTarget } from "./forge-pr";
|
|
8
|
+
import { StubForge } from "./fixtures/stub-forge";
|
|
9
|
+
import { journalKey } from "./journal";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Flow's PR/MR lifecycle: command shapes against a recording executor, and the
|
|
13
|
+
* draft → live → merged flow against the injected fake forge and a real local
|
|
14
|
+
* bare origin. No test contacts a real forge or pushes to the real origin.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
|
|
18
|
+
const TEST_ROOT = join(import.meta.dir, "..", ".tmp", "flow-pr-tests");
|
|
19
|
+
const FLOW_GOAL = "# Flow goal\n\nMake the fixture behavior observable.";
|
|
20
|
+
const TARGET: ForgeTarget = { forge: "github", host: "github.com", project: "acme/project" };
|
|
21
|
+
const GITLAB: ForgeTarget = { forge: "gitlab", host: "gitlab.example.test", project: "group/project" };
|
|
22
|
+
|
|
23
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
24
|
+
const cleanup: (() => void)[] = [];
|
|
25
|
+
afterAll(() => {
|
|
26
|
+
for (const action of cleanup.reverse()) action();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function testDirectory(name: string): string {
|
|
30
|
+
const directory = join(
|
|
31
|
+
TEST_ROOT,
|
|
32
|
+
`${name}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`,
|
|
33
|
+
);
|
|
34
|
+
mkdirSync(directory, { recursive: true });
|
|
35
|
+
return directory;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function git(cwd: string, args: string[]): string {
|
|
39
|
+
const result = Bun.spawnSync({
|
|
40
|
+
cmd: [
|
|
41
|
+
"git",
|
|
42
|
+
"-C",
|
|
43
|
+
cwd,
|
|
44
|
+
"-c",
|
|
45
|
+
"user.name=Flow Fixture",
|
|
46
|
+
"-c",
|
|
47
|
+
"user.email=fixture@example.test",
|
|
48
|
+
"-c",
|
|
49
|
+
"commit.gpgsign=false",
|
|
50
|
+
...args,
|
|
51
|
+
],
|
|
52
|
+
stdout: "pipe",
|
|
53
|
+
stderr: "pipe",
|
|
54
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "true" },
|
|
55
|
+
});
|
|
56
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
|
57
|
+
return result.stdout.toString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Recorded {
|
|
61
|
+
command: string;
|
|
62
|
+
args: string[];
|
|
63
|
+
env: Record<string, string> | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Responder = (command: string, args: string[]) => { stdout: string; stderr: string; code: number } | undefined;
|
|
67
|
+
|
|
68
|
+
/** An executor that records every invocation and can answer chosen commands. */
|
|
69
|
+
function recordingExecutor(
|
|
70
|
+
responder: Responder = () => undefined,
|
|
71
|
+
fallback?: Exec,
|
|
72
|
+
): {
|
|
73
|
+
exec: Exec;
|
|
74
|
+
calls: Recorded[];
|
|
75
|
+
} {
|
|
76
|
+
const calls: Recorded[] = [];
|
|
77
|
+
const exec: Exec = async (command, args, options) => {
|
|
78
|
+
calls.push({ command, args, env: options.env });
|
|
79
|
+
const answer = responder(command, args);
|
|
80
|
+
if (answer !== undefined) return answer;
|
|
81
|
+
if (fallback !== undefined) return await fallback(command, args, options);
|
|
82
|
+
try {
|
|
83
|
+
const child = Bun.spawn({
|
|
84
|
+
cmd: [command, ...args],
|
|
85
|
+
cwd: options.cwd,
|
|
86
|
+
env: options.env,
|
|
87
|
+
stdin: "ignore",
|
|
88
|
+
stdout: "pipe",
|
|
89
|
+
stderr: "pipe",
|
|
90
|
+
});
|
|
91
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
92
|
+
new Response(child.stdout).text(),
|
|
93
|
+
new Response(child.stderr).text(),
|
|
94
|
+
child.exited,
|
|
95
|
+
]);
|
|
96
|
+
return { stdout, stderr, code };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { stdout: "", stderr: error instanceof Error ? error.message : String(error), code: -1 };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return { exec, calls };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const AUTH = {
|
|
105
|
+
forge: "github" as const,
|
|
106
|
+
host: "github.com",
|
|
107
|
+
project: "acme/project",
|
|
108
|
+
login: "fixture",
|
|
109
|
+
token: "fixture-secret-token",
|
|
110
|
+
source: "test",
|
|
111
|
+
active: true,
|
|
112
|
+
permission: "push" as const,
|
|
113
|
+
canWrite: true,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function forge(responder: Responder) {
|
|
117
|
+
const { exec, calls } = recordingExecutor(responder);
|
|
118
|
+
const adapter = new CommandFlowForge(exec, {}, async () => AUTH);
|
|
119
|
+
return { adapter, calls, exec };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const PR_INPUT = {
|
|
123
|
+
cwd: TEST_ROOT,
|
|
124
|
+
target: TARGET,
|
|
125
|
+
head: "flow/abc12345",
|
|
126
|
+
base: "main",
|
|
127
|
+
title: "Flow goal",
|
|
128
|
+
body: "## Goal\n\nGoal text\n\n## Decisions\n\nBoard",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function argsOf(calls: Recorded[], command: string): string[][] {
|
|
132
|
+
return calls.filter((call) => call.command === command).map((call) => call.args);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("Flow forge command shapes", () => {
|
|
136
|
+
test("GitHub creates a draft, updates, readies, views, and merges with a merge commit", async () => {
|
|
137
|
+
const { adapter, calls } = forge((command, args) => {
|
|
138
|
+
if (command !== "gh") return undefined;
|
|
139
|
+
if (args[0] === "pr" && args[1] === "create") {
|
|
140
|
+
return { stdout: "https://github.com/acme/project/pull/12\n", stderr: "", code: 0 };
|
|
141
|
+
}
|
|
142
|
+
if (args[0] === "pr" && args[1] === "view") {
|
|
143
|
+
return {
|
|
144
|
+
stdout: JSON.stringify({
|
|
145
|
+
number: 12,
|
|
146
|
+
url: "https://github.com/acme/project/pull/12",
|
|
147
|
+
state: "OPEN",
|
|
148
|
+
isDraft: true,
|
|
149
|
+
}),
|
|
150
|
+
stderr: "",
|
|
151
|
+
code: 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { stdout: "", stderr: "", code: 0 };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const created = await adapter.createDraft(PR_INPUT);
|
|
158
|
+
expect(created).toEqual({
|
|
159
|
+
number: 12,
|
|
160
|
+
url: "https://github.com/acme/project/pull/12",
|
|
161
|
+
state: "open",
|
|
162
|
+
draft: true,
|
|
163
|
+
});
|
|
164
|
+
expect(argsOf(calls, "gh")[0]).toEqual([
|
|
165
|
+
"pr",
|
|
166
|
+
"create",
|
|
167
|
+
"--draft",
|
|
168
|
+
"--base",
|
|
169
|
+
"main",
|
|
170
|
+
"--head",
|
|
171
|
+
"flow/abc12345",
|
|
172
|
+
"--title",
|
|
173
|
+
"Flow goal",
|
|
174
|
+
"--body",
|
|
175
|
+
PR_INPUT.body,
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
await adapter.update({ ...PR_INPUT, number: 12 });
|
|
179
|
+
expect(argsOf(calls, "gh")[1]).toEqual([
|
|
180
|
+
"pr",
|
|
181
|
+
"edit",
|
|
182
|
+
"12",
|
|
183
|
+
"--title",
|
|
184
|
+
"Flow goal",
|
|
185
|
+
"--body",
|
|
186
|
+
PR_INPUT.body,
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
await adapter.markReady(TEST_ROOT, TARGET, 12);
|
|
190
|
+
expect(argsOf(calls, "gh")[2]).toEqual(["pr", "ready", "12"]);
|
|
191
|
+
|
|
192
|
+
const state = await adapter.view(TEST_ROOT, TARGET, 12);
|
|
193
|
+
expect(state.draft).toBe(true);
|
|
194
|
+
expect(state.state).toBe("open");
|
|
195
|
+
|
|
196
|
+
await adapter.merge(TEST_ROOT, TARGET, 12);
|
|
197
|
+
const mergeArgs = argsOf(calls, "gh")[4];
|
|
198
|
+
expect(mergeArgs).toEqual(["pr", "merge", "12", "--merge"]);
|
|
199
|
+
for (const args of argsOf(calls, "gh")) {
|
|
200
|
+
expect(args).not.toContain("--squash");
|
|
201
|
+
expect(args).not.toContain("--rebase");
|
|
202
|
+
expect(args).not.toContain("--admin");
|
|
203
|
+
expect(args.join(" ")).not.toContain(AUTH.token);
|
|
204
|
+
}
|
|
205
|
+
for (const call of calls) expect(call.args.join(" ")).not.toContain(AUTH.token);
|
|
206
|
+
const authenticated = calls.find((call) => call.command === "gh");
|
|
207
|
+
expect(authenticated?.env?.GH_TOKEN).toBe(AUTH.token);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("GitLab creates a draft MR, readies, and merges with a merge commit", async () => {
|
|
211
|
+
const { adapter, calls } = forge((command, args) => {
|
|
212
|
+
if (command !== "glab") return undefined;
|
|
213
|
+
if (args[0] === "mr" && args[1] === "create") {
|
|
214
|
+
return {
|
|
215
|
+
stdout: "https://gitlab.example.test/group/project/-/merge_requests/9\n",
|
|
216
|
+
stderr: "",
|
|
217
|
+
code: 0,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (args[0] === "mr" && args[1] === "view") {
|
|
221
|
+
return {
|
|
222
|
+
stdout: JSON.stringify({
|
|
223
|
+
iid: 9,
|
|
224
|
+
web_url: "https://gitlab.example.test/group/project/-/merge_requests/9",
|
|
225
|
+
state: "opened",
|
|
226
|
+
draft: true,
|
|
227
|
+
}),
|
|
228
|
+
stderr: "",
|
|
229
|
+
code: 0,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { stdout: "", stderr: "", code: 0 };
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const created = await adapter.createDraft({ ...PR_INPUT, target: GITLAB });
|
|
236
|
+
expect(created.number).toBe(9);
|
|
237
|
+
expect(created.url).toContain("merge_requests/9");
|
|
238
|
+
expect(created.draft).toBe(true);
|
|
239
|
+
expect(argsOf(calls, "glab")[0]).toEqual([
|
|
240
|
+
"mr",
|
|
241
|
+
"create",
|
|
242
|
+
"--draft",
|
|
243
|
+
"--source-branch",
|
|
244
|
+
"flow/abc12345",
|
|
245
|
+
"--target-branch",
|
|
246
|
+
"main",
|
|
247
|
+
"--title",
|
|
248
|
+
"Flow goal",
|
|
249
|
+
"--description",
|
|
250
|
+
PR_INPUT.body,
|
|
251
|
+
"--yes",
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
await adapter.update({ ...PR_INPUT, target: GITLAB, number: 9 });
|
|
255
|
+
expect(argsOf(calls, "glab")[1]).toEqual([
|
|
256
|
+
"mr",
|
|
257
|
+
"update",
|
|
258
|
+
"9",
|
|
259
|
+
"--title",
|
|
260
|
+
"Flow goal",
|
|
261
|
+
"--description",
|
|
262
|
+
PR_INPUT.body,
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
await adapter.markReady(TEST_ROOT, GITLAB, 9);
|
|
266
|
+
expect(argsOf(calls, "glab")[2]).toEqual(["mr", "update", "9", "--ready"]);
|
|
267
|
+
|
|
268
|
+
const state = await adapter.view(TEST_ROOT, GITLAB, 9);
|
|
269
|
+
expect(state).toMatchObject({ state: "open", draft: true, number: 9 });
|
|
270
|
+
|
|
271
|
+
await adapter.merge(TEST_ROOT, GITLAB, 9);
|
|
272
|
+
expect(argsOf(calls, "glab")[4]).toEqual(["mr", "merge", "9", "--merge", "--yes"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a missing write-capable account and a missing URL fail without inventing a PR", async () => {
|
|
276
|
+
const { exec, calls } = recordingExecutor();
|
|
277
|
+
const denied = new CommandFlowForge(exec, {}, async () => ({ error: "no write access" }));
|
|
278
|
+
await expect(denied.createDraft(PR_INPUT)).rejects.toThrow("no write access");
|
|
279
|
+
|
|
280
|
+
const silentExec = recordingExecutor(() => ({ stdout: "", stderr: "", code: 0 })).exec;
|
|
281
|
+
const silent = new CommandFlowForge(silentExec, {}, async () => AUTH);
|
|
282
|
+
await expect(silent.createDraft(PR_INPUT)).rejects.toThrow("no URL");
|
|
283
|
+
expect(calls.some((call) => call.command === "gh" && call.args.includes("issue"))).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("the remote branch deletion is a plain git push --delete", async () => {
|
|
287
|
+
const { adapter, calls } = forge(() => ({ stdout: "", stderr: "", code: 0 }));
|
|
288
|
+
await adapter.deleteRemoteBranch(TEST_ROOT, "flow/abc12345");
|
|
289
|
+
expect(calls[0]?.command).toBe("git");
|
|
290
|
+
expect(calls[0]?.args).toEqual(["push", "origin", "--delete", "flow/abc12345"]);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
interface Fixture {
|
|
295
|
+
root: string;
|
|
296
|
+
logPath: string;
|
|
297
|
+
stateDir: string;
|
|
298
|
+
goalFile: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function createFixture(name: string): Fixture {
|
|
302
|
+
const root = testDirectory(name);
|
|
303
|
+
mkdirSync(join(root, "adrs"), { recursive: true });
|
|
304
|
+
mkdirSync(join(root, "static"), { recursive: true });
|
|
305
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
306
|
+
writeFileSync(join(root, "static", "index.html"), "<!doctype html><title>Fixture</title>");
|
|
307
|
+
writeFileSync(join(root, "GOAL.md"), "# Host goal\n\nKeep me.\n");
|
|
308
|
+
writeFileSync(join(root, "adrs", "0001-fixture.md"), "# Fixture decision\n");
|
|
309
|
+
writeFileSync(
|
|
310
|
+
join(root, ".gitignore"),
|
|
311
|
+
[".worktree/", "node_modules/", "state/", "origin.git/", "*.log", ""].join("\n"),
|
|
312
|
+
);
|
|
313
|
+
writeFileSync(join(root, "src", "main.txt"), "base\n");
|
|
314
|
+
git(root, ["init", "-b", "main"]);
|
|
315
|
+
git(root, ["add", "-A"]);
|
|
316
|
+
git(root, ["commit", "-m", "chore: fixture base"]);
|
|
317
|
+
git(root, ["init", "--bare", join(root, "origin.git")]);
|
|
318
|
+
// A bare repository's HEAD defaults to the machine's init.defaultBranch; the
|
|
319
|
+
// fixture's clones must see main even where that default is master.
|
|
320
|
+
git(join(root, "origin.git"), ["symbolic-ref", "HEAD", "refs/heads/main"]);
|
|
321
|
+
git(root, ["remote", "add", "origin", join(root, "origin.git")]);
|
|
322
|
+
git(root, ["push", "-u", "origin", "main"]);
|
|
323
|
+
const fixture = {
|
|
324
|
+
root,
|
|
325
|
+
logPath: join(root, "agent.log"),
|
|
326
|
+
stateDir: join(root, "state"),
|
|
327
|
+
goalFile: join(root, "GOAL.md"),
|
|
328
|
+
};
|
|
329
|
+
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
|
|
330
|
+
return fixture;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface TestServer {
|
|
334
|
+
server: Server<unknown>;
|
|
335
|
+
fixture: Fixture;
|
|
336
|
+
forge: StubForge;
|
|
337
|
+
url: string;
|
|
338
|
+
calls: Recorded[];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
interface StartOptions {
|
|
342
|
+
stub?: string[];
|
|
343
|
+
forge?: StubForge;
|
|
344
|
+
exec?: Exec;
|
|
345
|
+
/** `null` leaves the real origin classification in place. */
|
|
346
|
+
forgeTarget?: ForgeTarget | null;
|
|
347
|
+
watchMs?: number;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function startServer(fixture: Fixture, options: StartOptions = {}): TestServer {
|
|
351
|
+
const forgeStub = options.forge ?? new StubForge({ logPath: join(fixture.root, "forge.log") });
|
|
352
|
+
const recorder = recordingExecutor(() => undefined, options.exec);
|
|
353
|
+
const calls = recorder.calls;
|
|
354
|
+
const command = [`bun ${STUB_AGENT}`, `--log=${fixture.logPath}`, ...(options.stub ?? [])].join(" ");
|
|
355
|
+
const server = createApp({
|
|
356
|
+
adrDir: join(fixture.root, "adrs"),
|
|
357
|
+
staticDir: join(fixture.root, "static"),
|
|
358
|
+
hostname: "127.0.0.1",
|
|
359
|
+
port: 0,
|
|
360
|
+
repoDir: fixture.root,
|
|
361
|
+
stateDir: fixture.stateDir,
|
|
362
|
+
goalFile: fixture.goalFile,
|
|
363
|
+
agentCommand: command,
|
|
364
|
+
tracker: async () => undefined,
|
|
365
|
+
trackerSettle: { attempts: 1 },
|
|
366
|
+
flow: {
|
|
367
|
+
forge: forgeStub,
|
|
368
|
+
...(options.forgeTarget === null
|
|
369
|
+
? {}
|
|
370
|
+
: { forgeTarget: options.forgeTarget ?? TARGET }),
|
|
371
|
+
exec: recorder.exec,
|
|
372
|
+
watchMs: options.watchMs ?? 40,
|
|
373
|
+
analystTimeoutMs: 10_000,
|
|
374
|
+
goalTimeoutMs: 10_000,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
cleanup.push(() => server.stop(true));
|
|
378
|
+
return {
|
|
379
|
+
server,
|
|
380
|
+
fixture,
|
|
381
|
+
forge: forgeStub,
|
|
382
|
+
url: `127.0.0.1:${server.port}`,
|
|
383
|
+
calls,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
type Event = Record<string, unknown>;
|
|
388
|
+
|
|
389
|
+
interface TestClient {
|
|
390
|
+
events: Event[];
|
|
391
|
+
send(command: unknown): void;
|
|
392
|
+
waitFor(predicate: (event: Event) => boolean, timeout?: number): Promise<Event>;
|
|
393
|
+
close(): void;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function connect(testServer: TestServer): Promise<TestClient> {
|
|
397
|
+
const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
|
|
398
|
+
const socket = new WebSocket(`ws://${testServer.url}/ws`);
|
|
399
|
+
const events: Event[] = [];
|
|
400
|
+
const waiters: {
|
|
401
|
+
predicate: (event: Event) => boolean;
|
|
402
|
+
resolve: (event: Event) => void;
|
|
403
|
+
reject: (error: Error) => void;
|
|
404
|
+
timer: Timer;
|
|
405
|
+
}[] = [];
|
|
406
|
+
|
|
407
|
+
socket.onopen = () => {
|
|
408
|
+
resolve({
|
|
409
|
+
events,
|
|
410
|
+
send(command) {
|
|
411
|
+
socket.send(JSON.stringify(command));
|
|
412
|
+
},
|
|
413
|
+
waitFor(predicate, timeout = 20_000) {
|
|
414
|
+
const found = events.find(predicate);
|
|
415
|
+
if (found !== undefined) return Promise.resolve(found);
|
|
416
|
+
const { promise: waiting, resolve: settle, reject: fail } =
|
|
417
|
+
Promise.withResolvers<Event>();
|
|
418
|
+
const timer = setTimeout(() => {
|
|
419
|
+
const index = waiters.findIndex((waiter) => waiter.resolve === settle);
|
|
420
|
+
if (index >= 0) waiters.splice(index, 1);
|
|
421
|
+
fail(new Error(`Timed out. Saw: ${JSON.stringify(events)}`));
|
|
422
|
+
}, timeout);
|
|
423
|
+
waiters.push({ predicate, resolve: settle, reject: fail, timer });
|
|
424
|
+
return waiting;
|
|
425
|
+
},
|
|
426
|
+
close() {
|
|
427
|
+
socket.close();
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
socket.onmessage = (message) => {
|
|
433
|
+
let event: Event;
|
|
434
|
+
try {
|
|
435
|
+
event = JSON.parse(String(message.data)) as Event;
|
|
436
|
+
} catch {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
events.push(event);
|
|
440
|
+
for (let index = 0; index < waiters.length; index += 1) {
|
|
441
|
+
const waiter = waiters[index];
|
|
442
|
+
if (waiter === undefined) continue;
|
|
443
|
+
const candidate = events.find(waiter.predicate);
|
|
444
|
+
if (candidate === undefined) continue;
|
|
445
|
+
waiters.splice(index, 1);
|
|
446
|
+
index -= 1;
|
|
447
|
+
clearTimeout(waiter.timer);
|
|
448
|
+
waiter.resolve(candidate);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
socket.onerror = () => reject(new Error("WebSocket failed"));
|
|
452
|
+
return promise;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** A round trip proving the socket is open before a command is sent. */
|
|
456
|
+
async function connected(client: TestClient): Promise<void> {
|
|
457
|
+
client.send({ type: "getProject" });
|
|
458
|
+
await client.waitFor((event) => event.type === "project");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function waitUntil(predicate: () => boolean, description: string, timeout = 20_000) {
|
|
462
|
+
const deadline = Date.now() + timeout;
|
|
463
|
+
while (Date.now() < deadline) {
|
|
464
|
+
if (predicate()) return;
|
|
465
|
+
await Bun.sleep(10);
|
|
466
|
+
}
|
|
467
|
+
throw new Error(`Timed out waiting for ${description}.`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function latest(client: TestClient, type: string): Event | undefined {
|
|
471
|
+
return [...client.events].reverse().find((event) => event.type === type);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function waitForStub(fixture: Fixture, predicate: (line: string) => boolean, timeout = 10_000) {
|
|
475
|
+
const deadline = Date.now() + timeout;
|
|
476
|
+
const read = () => {
|
|
477
|
+
try {
|
|
478
|
+
return readFileSync(fixture.logPath, "utf8").split("\n");
|
|
479
|
+
} catch {
|
|
480
|
+
return [];
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
while (Date.now() < deadline) {
|
|
484
|
+
if (read().some(predicate)) return;
|
|
485
|
+
await Bun.sleep(10);
|
|
486
|
+
}
|
|
487
|
+
throw new Error(`Timed out waiting for the stub log: ${JSON.stringify(read())}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function spawnCount(fixture: Fixture): number {
|
|
491
|
+
try {
|
|
492
|
+
return readFileSync(fixture.logPath, "utf8")
|
|
493
|
+
.split("\n")
|
|
494
|
+
.filter((line) => line.startsWith("spawn ")).length;
|
|
495
|
+
} catch {
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function createGoal(client: TestClient): Promise<void> {
|
|
501
|
+
client.send({ type: "createFlowGoal" });
|
|
502
|
+
const question = await client.waitFor((event) => event.type === "flowQuestion");
|
|
503
|
+
client.send({ type: "flowAnswer", target: "goal", id: question.id, value: "Alpha" });
|
|
504
|
+
await client.waitFor((event) => event.type === "flowGoalStopped" && event.completed === true);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function sessionOf(fixture: Fixture): { id: string; worktree: string; branch: string } {
|
|
508
|
+
const directory = join(fixture.stateDir, "flow", journalKey(fixture.root));
|
|
509
|
+
const id = readdirSyncSafe(directory)[0] as string;
|
|
510
|
+
const session = JSON.parse(readFileSync(join(directory, id, "session.json"), "utf8")) as {
|
|
511
|
+
worktree: string;
|
|
512
|
+
branch: string;
|
|
513
|
+
};
|
|
514
|
+
return { id, worktree: session.worktree, branch: session.branch };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function readdirSyncSafe(path: string): string[] {
|
|
518
|
+
try {
|
|
519
|
+
return readdirSync(path);
|
|
520
|
+
} catch {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function startFlow(
|
|
526
|
+
client: TestClient,
|
|
527
|
+
fixture: Fixture,
|
|
528
|
+
): Promise<{ id: string; worktree: string; branch: string }> {
|
|
529
|
+
client.send({ type: "startFlowImplementation" });
|
|
530
|
+
await client.waitFor((event) => event.type === "flowImplementationStarted");
|
|
531
|
+
return sessionOf(fixture);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Ends the implementation whether it is still running or already finished. */
|
|
535
|
+
async function stopFlow(client: TestClient): Promise<void> {
|
|
536
|
+
client.send({ type: "stopFlowImplementation" });
|
|
537
|
+
await client.waitFor(
|
|
538
|
+
(event) =>
|
|
539
|
+
event.type === "flowStopped" ||
|
|
540
|
+
(event.type === "flowError" && String(event.message).includes("not running")),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
describe("Flow pull-request lifecycle", () => {
|
|
545
|
+
test("no PR exists before the first checkpoint; the first one is a draft against main", async () => {
|
|
546
|
+
const fixture = createFixture("pr-draft");
|
|
547
|
+
const forgeStub = new StubForge();
|
|
548
|
+
const server = startServer(fixture, {
|
|
549
|
+
forge: forgeStub,
|
|
550
|
+
stub: [
|
|
551
|
+
"--flow-goal",
|
|
552
|
+
"--flow-implement",
|
|
553
|
+
"--flow-change",
|
|
554
|
+
"--flow-pre-commit-delay=1200",
|
|
555
|
+
],
|
|
556
|
+
});
|
|
557
|
+
const client = await connect(server);
|
|
558
|
+
await connected(client);
|
|
559
|
+
await createGoal(client);
|
|
560
|
+
client.send({ type: "saveFlowDecisions", text: "# Board\n\nKeep it small.\n" });
|
|
561
|
+
await client.waitFor((event) => event.type === "flowDecisions");
|
|
562
|
+
const session = await startFlow(client, fixture);
|
|
563
|
+
|
|
564
|
+
expect(forgeStub.calls).toHaveLength(0);
|
|
565
|
+
expect(forgeStub.pullRequest).toBeUndefined();
|
|
566
|
+
client.send({ type: "getProject" });
|
|
567
|
+
await client.waitFor((event) => event.type === "project");
|
|
568
|
+
|
|
569
|
+
const pr = await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
570
|
+
const pull = pr.pr as { state: string; number: number; url: string; forge: string };
|
|
571
|
+
expect(pull.state).toBe("draft");
|
|
572
|
+
expect(pull.number).toBe(7);
|
|
573
|
+
expect(pull.forge).toBe("github");
|
|
574
|
+
expect(pull.url).toContain("/pull/7");
|
|
575
|
+
|
|
576
|
+
const create = forgeStub.calls.find((call) => call.op === "createDraft");
|
|
577
|
+
expect(create).toBeDefined();
|
|
578
|
+
const input = create?.input as { head: string; base: string; title: string; body: string };
|
|
579
|
+
expect(input.head).toBe(session.branch);
|
|
580
|
+
expect(input.base).toBe("main");
|
|
581
|
+
expect(input.title).toBe("Flow goal");
|
|
582
|
+
expect(input.body).toContain(FLOW_GOAL);
|
|
583
|
+
expect(input.body).toContain("Keep it small.");
|
|
584
|
+
expect(git(fixture.root, ["--git-dir", join(fixture.root, "origin.git"), "rev-parse", session.branch]).trim()).toMatch(/^[0-9a-f]{40}$/);
|
|
585
|
+
|
|
586
|
+
const pushes = server.calls.filter((call) => call.command === "git" && call.args.includes("push"));
|
|
587
|
+
for (const push of pushes) {
|
|
588
|
+
expect(push.args).not.toContain("--force");
|
|
589
|
+
expect(push.args).not.toContain("-f");
|
|
590
|
+
}
|
|
591
|
+
expect(forgeStub.calls.some((call) => /issue|epic|label|search/.test(JSON.stringify(call)))).toBe(false);
|
|
592
|
+
await stopFlow(client);
|
|
593
|
+
client.close();
|
|
594
|
+
}, 60_000);
|
|
595
|
+
|
|
596
|
+
test("every later checkpoint pushes without force and updates the PR", async () => {
|
|
597
|
+
const fixture = createFixture("pr-updates");
|
|
598
|
+
const forgeStub = new StubForge();
|
|
599
|
+
const server = startServer(fixture, {
|
|
600
|
+
forge: forgeStub,
|
|
601
|
+
stub: [
|
|
602
|
+
"--flow-goal",
|
|
603
|
+
"--flow-implement",
|
|
604
|
+
"--flow-change",
|
|
605
|
+
"--flow-checkpoints=2",
|
|
606
|
+
`--flow-wait-file=${join(fixture.stateDir, "proceed")}`,
|
|
607
|
+
],
|
|
608
|
+
});
|
|
609
|
+
const client = await connect(server);
|
|
610
|
+
await connected(client);
|
|
611
|
+
await createGoal(client);
|
|
612
|
+
const session = await startFlow(client, fixture);
|
|
613
|
+
await client.waitFor((event) => event.type === "flowPr" && (event.pr as { state: string }).state === "draft");
|
|
614
|
+
// The stub holds its second checkpoint until the first draft PR exists, so
|
|
615
|
+
// the update is deterministic rather than dependent on watcher timing.
|
|
616
|
+
mkdirSync(fixture.stateDir, { recursive: true });
|
|
617
|
+
writeFileSync(join(fixture.stateDir, "proceed"), "second checkpoint\n");
|
|
618
|
+
await waitUntil(
|
|
619
|
+
() => forgeStub.calls.some((call) => call.op === "update"),
|
|
620
|
+
"the PR body update",
|
|
621
|
+
40_000,
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const pushes = server.calls.filter(
|
|
625
|
+
(call) => call.command === "git" && call.args.includes("push") && call.args.includes(session.branch),
|
|
626
|
+
);
|
|
627
|
+
expect(pushes.length).toBeGreaterThanOrEqual(2);
|
|
628
|
+
for (const push of pushes) {
|
|
629
|
+
expect(push.args).not.toContain("--force");
|
|
630
|
+
expect(push.args).not.toContain("-f");
|
|
631
|
+
}
|
|
632
|
+
expect(forgeStub.calls.filter((call) => call.op === "createDraft")).toHaveLength(1);
|
|
633
|
+
expect(forgeStub.calls.filter((call) => call.op === "update").length).toBeGreaterThanOrEqual(1);
|
|
634
|
+
await stopFlow(client);
|
|
635
|
+
client.close();
|
|
636
|
+
}, 60_000);
|
|
637
|
+
|
|
638
|
+
test("Ready refuses a running or dirty session and accepts a stopped, clean draft", async () => {
|
|
639
|
+
const fixture = createFixture("pr-ready");
|
|
640
|
+
const forgeStub = new StubForge();
|
|
641
|
+
const server = startServer(fixture, {
|
|
642
|
+
forge: forgeStub,
|
|
643
|
+
stub: [
|
|
644
|
+
"--flow-goal",
|
|
645
|
+
"--flow-implement",
|
|
646
|
+
"--flow-change",
|
|
647
|
+
"--flow-checkpoints=2",
|
|
648
|
+
"--flow-step-delay=8000",
|
|
649
|
+
],
|
|
650
|
+
});
|
|
651
|
+
const client = await connect(server);
|
|
652
|
+
await connected(client);
|
|
653
|
+
await createGoal(client);
|
|
654
|
+
const session = await startFlow(client, fixture);
|
|
655
|
+
await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
656
|
+
|
|
657
|
+
client.send({ type: "markFlowPrReady" });
|
|
658
|
+
const running = await client.waitFor((event) => event.type === "flowError");
|
|
659
|
+
expect(String(running.message)).toContain("Stop the Flow implementation");
|
|
660
|
+
await stopFlow(client);
|
|
661
|
+
|
|
662
|
+
writeFileSync(join(session.worktree, "dirty.txt"), "uncommitted\n");
|
|
663
|
+
client.send({ type: "markFlowPrReady" });
|
|
664
|
+
const dirty = await client.waitFor(
|
|
665
|
+
(event) => event.type === "flowError" && String(event.message).includes("Commit or discard"),
|
|
666
|
+
);
|
|
667
|
+
expect(String(dirty.message)).toContain("Commit");
|
|
668
|
+
rmSync(join(session.worktree, "dirty.txt"));
|
|
669
|
+
|
|
670
|
+
await waitUntil(() => true, "noop");
|
|
671
|
+
client.send({ type: "markFlowPrReady" });
|
|
672
|
+
const live = await client.waitFor(
|
|
673
|
+
(event) => event.type === "flowPr" && (event.pr as { state: string }).state === "live",
|
|
674
|
+
);
|
|
675
|
+
expect((live.pr as { state: string }).state).toBe("live");
|
|
676
|
+
expect(forgeStub.calls.some((call) => call.op === "markReady")).toBe(true);
|
|
677
|
+
client.close();
|
|
678
|
+
}, 60_000);
|
|
679
|
+
|
|
680
|
+
test("Merge refuses a draft, a running session, a dirty worktree, and a forge failure", async () => {
|
|
681
|
+
const fixture = createFixture("pr-merge-refusals");
|
|
682
|
+
const forgeStub = new StubForge({ failMerge: "checks are pending" });
|
|
683
|
+
const server = startServer(fixture, {
|
|
684
|
+
forge: forgeStub,
|
|
685
|
+
stub: [
|
|
686
|
+
"--flow-goal",
|
|
687
|
+
"--flow-implement",
|
|
688
|
+
"--flow-change",
|
|
689
|
+
"--flow-checkpoints=2",
|
|
690
|
+
"--flow-step-delay=8000",
|
|
691
|
+
],
|
|
692
|
+
});
|
|
693
|
+
const client = await connect(server);
|
|
694
|
+
await connected(client);
|
|
695
|
+
await createGoal(client);
|
|
696
|
+
const session = await startFlow(client, fixture);
|
|
697
|
+
await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
698
|
+
|
|
699
|
+
client.send({ type: "mergeFlowPr" });
|
|
700
|
+
const running = await client.waitFor((event) => event.type === "flowError");
|
|
701
|
+
expect(String(running.message)).toContain("Stop the Flow implementation");
|
|
702
|
+
await stopFlow(client);
|
|
703
|
+
|
|
704
|
+
client.send({ type: "mergeFlowPr" });
|
|
705
|
+
const draft = await client.waitFor(
|
|
706
|
+
(event) => event.type === "flowError" && String(event.message).includes("Only a live"),
|
|
707
|
+
);
|
|
708
|
+
expect(String(draft.message)).toContain("live");
|
|
709
|
+
|
|
710
|
+
client.send({ type: "markFlowPrReady" });
|
|
711
|
+
await client.waitFor((event) => event.type === "flowPr" && (event.pr as { state: string }).state === "live");
|
|
712
|
+
writeFileSync(join(session.worktree, "dirty.txt"), "uncommitted\n");
|
|
713
|
+
client.send({ type: "mergeFlowPr" });
|
|
714
|
+
const dirty = await client.waitFor(
|
|
715
|
+
(event) => event.type === "flowError" && String(event.message).includes("Commit or discard"),
|
|
716
|
+
);
|
|
717
|
+
expect(String(dirty.message)).toContain("Commit");
|
|
718
|
+
rmSync(join(session.worktree, "dirty.txt"));
|
|
719
|
+
|
|
720
|
+
client.send({ type: "mergeFlowPr" });
|
|
721
|
+
const failed = await client.waitFor(
|
|
722
|
+
(event) => event.type === "flowError" && String(event.message).includes("checks are pending"),
|
|
723
|
+
);
|
|
724
|
+
expect(String(failed.message)).toContain("checks are pending");
|
|
725
|
+
expect(existsSync(session.worktree)).toBe(true);
|
|
726
|
+
expect((latest(client, "flowPr")?.pr as { state: string }).state).toBe("live");
|
|
727
|
+
client.close();
|
|
728
|
+
}, 60_000);
|
|
729
|
+
|
|
730
|
+
test("a merge commit merges, fast-forwards main, removes the worktree, and deletes both branches", async () => {
|
|
731
|
+
const fixture = createFixture("pr-merge");
|
|
732
|
+
const forgeStub = new StubForge();
|
|
733
|
+
const server = startServer(fixture, {
|
|
734
|
+
forge: forgeStub,
|
|
735
|
+
stub: [
|
|
736
|
+
"--flow-goal",
|
|
737
|
+
"--flow-implement",
|
|
738
|
+
"--flow-change",
|
|
739
|
+
"--flow-checkpoints=2",
|
|
740
|
+
"--flow-step-delay=8000",
|
|
741
|
+
],
|
|
742
|
+
});
|
|
743
|
+
const client = await connect(server);
|
|
744
|
+
await connected(client);
|
|
745
|
+
await createGoal(client);
|
|
746
|
+
const session = await startFlow(client, fixture);
|
|
747
|
+
await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
748
|
+
await stopFlow(client);
|
|
749
|
+
client.send({ type: "markFlowPrReady" });
|
|
750
|
+
await client.waitFor((event) => event.type === "flowPr" && (event.pr as { state: string }).state === "live");
|
|
751
|
+
|
|
752
|
+
// The forge merges the PR: reproduce the merge commit on the bare origin.
|
|
753
|
+
const clone = join(fixture.root, ".worktree", "merge-clone");
|
|
754
|
+
git(fixture.root, ["clone", join(fixture.root, "origin.git"), clone]);
|
|
755
|
+
git(clone, [
|
|
756
|
+
"-c",
|
|
757
|
+
"user.name=Forge",
|
|
758
|
+
"-c",
|
|
759
|
+
"user.email=forge@example.test",
|
|
760
|
+
"-c",
|
|
761
|
+
"commit.gpgsign=false",
|
|
762
|
+
"merge",
|
|
763
|
+
"--no-ff",
|
|
764
|
+
`origin/${session.branch}`,
|
|
765
|
+
"-m",
|
|
766
|
+
`Merge pull request #7 from ${session.branch}`,
|
|
767
|
+
]);
|
|
768
|
+
git(clone, ["push", "origin", "main"]);
|
|
769
|
+
const hostBefore = git(fixture.root, ["rev-parse", "main"]).trim();
|
|
770
|
+
const originMain = git(clone, ["rev-parse", "main"]).trim();
|
|
771
|
+
expect(hostBefore).not.toBe(originMain);
|
|
772
|
+
|
|
773
|
+
client.send({ type: "mergeFlowPr" });
|
|
774
|
+
await client.waitFor((event) => event.type === "flowPr" && (event.pr as { state: string }).state === "merged");
|
|
775
|
+
await client.waitFor((event) => event.type === "flowSnapshot" && event.status === "merged");
|
|
776
|
+
|
|
777
|
+
expect(forgeStub.calls.find((call) => call.op === "merge")?.input).toMatchObject({ method: "merge" });
|
|
778
|
+
expect(forgeStub.calls.some((call) => call.op === "deleteRemoteBranch" && call.branch === session.branch)).toBe(true);
|
|
779
|
+
expect(existsSync(session.worktree)).toBe(false);
|
|
780
|
+
expect(git(fixture.root, ["rev-parse", "main"]).trim()).toBe(originMain);
|
|
781
|
+
expect(git(fixture.root, ["status", "--porcelain"]).trim()).toBe("");
|
|
782
|
+
const localBranch = Bun.spawnSync({
|
|
783
|
+
cmd: ["git", "-C", fixture.root, "rev-parse", "--verify", `refs/heads/${session.branch}`],
|
|
784
|
+
stdout: "pipe",
|
|
785
|
+
stderr: "pipe",
|
|
786
|
+
});
|
|
787
|
+
expect(localBranch.exitCode).not.toBe(0);
|
|
788
|
+
|
|
789
|
+
const merged = latest(client, "flowSnapshot");
|
|
790
|
+
expect(merged?.leftover).toBeNull();
|
|
791
|
+
client.send({ type: "startFlowImplementation" });
|
|
792
|
+
const refused = await client.waitFor(
|
|
793
|
+
(event) => event.type === "flowError" && String(event.message).includes("merged"),
|
|
794
|
+
);
|
|
795
|
+
expect(String(refused.message)).toContain("merged");
|
|
796
|
+
client.send({ type: "markFlowPrReady" });
|
|
797
|
+
await client.waitFor(
|
|
798
|
+
(event) => event.type === "flowError" && String(event.message).includes("Only a draft"),
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
// The merged session keeps its files, and a new goal starts a new session.
|
|
802
|
+
const oldDirectory = sessionOf(fixture);
|
|
803
|
+
const count = (type: string) => client.events.filter((event) => event.type === type).length;
|
|
804
|
+
const questionsBefore = count("flowQuestion");
|
|
805
|
+
client.send({ type: "createFlowGoal" });
|
|
806
|
+
await waitUntil(() => count("flowQuestion") > questionsBefore, "the new goal question");
|
|
807
|
+
const question = [...client.events].reverse().find((event) => event.type === "flowQuestion");
|
|
808
|
+
client.send({ type: "flowAnswer", target: "goal", id: question?.id, value: "Alpha" });
|
|
809
|
+
await waitUntil(
|
|
810
|
+
() =>
|
|
811
|
+
client.events.some(
|
|
812
|
+
(event) => event.type === "flowSnapshot" && event.sessionId !== oldDirectory.id,
|
|
813
|
+
),
|
|
814
|
+
"a new session after the merge",
|
|
815
|
+
);
|
|
816
|
+
expect(existsSync(join(fixture.stateDir, "flow", journalKey(fixture.root), oldDirectory.id, "goal.md"))).toBe(true);
|
|
817
|
+
client.close();
|
|
818
|
+
}, 90_000);
|
|
819
|
+
|
|
820
|
+
test("cleanup failure keeps the merged PR, the worktree, and its branch and reports both", async () => {
|
|
821
|
+
const fixture = createFixture("pr-cleanup-failure");
|
|
822
|
+
const forgeStub = new StubForge();
|
|
823
|
+
const recorder = recordingExecutor();
|
|
824
|
+
const failing: Exec = async (command, args, options) => {
|
|
825
|
+
if (command === "git" && args.includes("worktree") && args.includes("remove")) {
|
|
826
|
+
recorder.calls.push({ command, args, env: options.env });
|
|
827
|
+
return { stdout: "", stderr: "fatal: worktree is locked", code: 128 };
|
|
828
|
+
}
|
|
829
|
+
return recorder.exec(command, args, options);
|
|
830
|
+
};
|
|
831
|
+
const server = startServer(fixture, {
|
|
832
|
+
forge: forgeStub,
|
|
833
|
+
exec: failing,
|
|
834
|
+
stub: [
|
|
835
|
+
"--flow-goal",
|
|
836
|
+
"--flow-implement",
|
|
837
|
+
"--flow-change",
|
|
838
|
+
"--flow-checkpoints=2",
|
|
839
|
+
"--flow-step-delay=8000",
|
|
840
|
+
],
|
|
841
|
+
});
|
|
842
|
+
const client = await connect(server);
|
|
843
|
+
await connected(client);
|
|
844
|
+
await createGoal(client);
|
|
845
|
+
const session = await startFlow(client, fixture);
|
|
846
|
+
await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
847
|
+
await stopFlow(client);
|
|
848
|
+
client.send({ type: "markFlowPrReady" });
|
|
849
|
+
await client.waitFor((event) => event.type === "flowPr" && (event.pr as { state: string }).state === "live");
|
|
850
|
+
|
|
851
|
+
client.send({ type: "mergeFlowPr" });
|
|
852
|
+
const merged = await client.waitFor((event) => event.type === "flowSnapshot" && event.status === "merged");
|
|
853
|
+
expect(String(merged.leftover)).toContain(session.worktree);
|
|
854
|
+
expect(String(merged.leftover)).toContain(session.branch);
|
|
855
|
+
expect(existsSync(session.worktree)).toBe(true);
|
|
856
|
+
expect((merged.pr as { state: string }).state).toBe("merged");
|
|
857
|
+
client.close();
|
|
858
|
+
}, 90_000);
|
|
859
|
+
|
|
860
|
+
test("a rejected push produces one error, no PR, and a retry that succeeds", async () => {
|
|
861
|
+
const fixture = createFixture("pr-retry");
|
|
862
|
+
const forgeStub = new StubForge();
|
|
863
|
+
let rejectPush = true;
|
|
864
|
+
const recorder = recordingExecutor();
|
|
865
|
+
const flaky: Exec = async (command, args, options) => {
|
|
866
|
+
if (command === "git" && args.includes("push") && rejectPush) {
|
|
867
|
+
recorder.calls.push({ command, args, env: options.env });
|
|
868
|
+
return { stdout: "", stderr: "! [rejected] non-fast-forward", code: 1 };
|
|
869
|
+
}
|
|
870
|
+
return recorder.exec(command, args, options);
|
|
871
|
+
};
|
|
872
|
+
const server = startServer(fixture, {
|
|
873
|
+
forge: forgeStub,
|
|
874
|
+
exec: flaky,
|
|
875
|
+
stub: ["--flow-goal", "--flow-implement", "--flow-change"],
|
|
876
|
+
});
|
|
877
|
+
const client = await connect(server);
|
|
878
|
+
await connected(client);
|
|
879
|
+
await createGoal(client);
|
|
880
|
+
await startFlow(client, fixture);
|
|
881
|
+
const error = await client.waitFor((event) => event.type === "flowError");
|
|
882
|
+
expect(String(error.message)).toContain("rejected");
|
|
883
|
+
expect(forgeStub.pullRequest).toBeUndefined();
|
|
884
|
+
const snapshot = await client.waitFor(
|
|
885
|
+
(event) => event.type === "flowSnapshot" && String(event.prError).includes("rejected"),
|
|
886
|
+
);
|
|
887
|
+
expect(String(snapshot.prError)).toContain("rejected");
|
|
888
|
+
expect(existsSync((latest(client, "flowImplementationStarted")?.worktree as string))).toBe(true);
|
|
889
|
+
|
|
890
|
+
rejectPush = false;
|
|
891
|
+
client.send({ type: "retryFlowPr" });
|
|
892
|
+
const pr = await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
893
|
+
expect((pr.pr as { state: string }).state).toBe("draft");
|
|
894
|
+
expect(forgeStub.calls.filter((call) => call.op === "createDraft")).toHaveLength(1);
|
|
895
|
+
for (const call of server.calls) {
|
|
896
|
+
const text = `${call.command} ${call.args.join(" ")}`;
|
|
897
|
+
expect(text).not.toContain(" issue ");
|
|
898
|
+
expect(text).not.toContain("label list");
|
|
899
|
+
expect(text).not.toContain("search issues");
|
|
900
|
+
expect(text).not.toContain("epic");
|
|
901
|
+
}
|
|
902
|
+
await stopFlow(client);
|
|
903
|
+
client.close();
|
|
904
|
+
}, 60_000);
|
|
905
|
+
|
|
906
|
+
test("a forge failure keeps the session, invents no PR, and a retry creates the draft", async () => {
|
|
907
|
+
const fixture = createFixture("pr-forge-failure");
|
|
908
|
+
const forgeStub = new StubForge({ failCreate: "forge unavailable" });
|
|
909
|
+
const server = startServer(fixture, {
|
|
910
|
+
forge: forgeStub,
|
|
911
|
+
stub: ["--flow-goal", "--flow-implement", "--flow-change"],
|
|
912
|
+
});
|
|
913
|
+
const client = await connect(server);
|
|
914
|
+
await connected(client);
|
|
915
|
+
await createGoal(client);
|
|
916
|
+
const session = await startFlow(client, fixture);
|
|
917
|
+
const error = await client.waitFor((event) => event.type === "flowError");
|
|
918
|
+
expect(String(error.message)).toContain("forge unavailable");
|
|
919
|
+
expect(forgeStub.pullRequest).toBeUndefined();
|
|
920
|
+
expect(existsSync(session.worktree)).toBe(true);
|
|
921
|
+
|
|
922
|
+
forgeStub.clearFailures();
|
|
923
|
+
client.send({ type: "retryFlowPr" });
|
|
924
|
+
const pr = await client.waitFor((event) => event.type === "flowPr" && event.pr !== null);
|
|
925
|
+
expect((pr.pr as { state: string }).state).toBe("draft");
|
|
926
|
+
await stopFlow(client);
|
|
927
|
+
client.close();
|
|
928
|
+
}, 60_000);
|
|
929
|
+
|
|
930
|
+
test("an origin that is not GitHub or GitLab produces an error and no PR", async () => {
|
|
931
|
+
const fixture = createFixture("pr-unsupported");
|
|
932
|
+
const forgeStub = new StubForge();
|
|
933
|
+
const origin = join(fixture.root, "origin.git");
|
|
934
|
+
git(fixture.root, ["remote", "set-url", "origin", origin]);
|
|
935
|
+
const server = startServer(fixture, {
|
|
936
|
+
forge: forgeStub,
|
|
937
|
+
forgeTarget: null,
|
|
938
|
+
stub: ["--flow-goal", "--flow-implement", "--flow-change"],
|
|
939
|
+
});
|
|
940
|
+
const client = await connect(server);
|
|
941
|
+
await connected(client);
|
|
942
|
+
await createGoal(client);
|
|
943
|
+
await startFlow(client, fixture);
|
|
944
|
+
const error = await client.waitFor((event) => event.type === "flowError");
|
|
945
|
+
expect(String(error.message)).toContain("does not name a supported repository");
|
|
946
|
+
expect(forgeStub.calls).toHaveLength(0);
|
|
947
|
+
await stopFlow(client);
|
|
948
|
+
client.close();
|
|
949
|
+
}, 60_000);
|
|
950
|
+
|
|
951
|
+
test("no Flow operation ever spawns a goal or implementation peer twice", async () => {
|
|
952
|
+
const fixture = createFixture("pr-spawns");
|
|
953
|
+
const forgeStub = new StubForge();
|
|
954
|
+
const server = startServer(fixture, {
|
|
955
|
+
forge: forgeStub,
|
|
956
|
+
stub: [
|
|
957
|
+
"--flow-goal",
|
|
958
|
+
"--flow-implement",
|
|
959
|
+
"--flow-change",
|
|
960
|
+
"--flow-checkpoints=2",
|
|
961
|
+
"--flow-step-delay=8000",
|
|
962
|
+
],
|
|
963
|
+
});
|
|
964
|
+
const client = await connect(server);
|
|
965
|
+
await connected(client);
|
|
966
|
+
await createGoal(client);
|
|
967
|
+
await startFlow(client, fixture);
|
|
968
|
+
await waitForStub(fixture, (line) => line.startsWith("prompt /flow-implement "));
|
|
969
|
+
client.send({ type: "startFlowImplementation" });
|
|
970
|
+
const error = await client.waitFor((event) => event.type === "flowError");
|
|
971
|
+
expect(String(error.message)).toContain("already running");
|
|
972
|
+
expect(spawnCount(fixture)).toBe(2);
|
|
973
|
+
await stopFlow(client);
|
|
974
|
+
client.close();
|
|
975
|
+
}, 60_000);
|
|
976
|
+
});
|