@dsdenes/devboard 0.1.560 → 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,140 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
hostHarnessDir,
|
|
8
|
+
installedPackageDir,
|
|
9
|
+
readHarnessVersion,
|
|
10
|
+
runningHarnessDir,
|
|
11
|
+
serverVersion,
|
|
12
|
+
} from "./version";
|
|
13
|
+
|
|
14
|
+
const harnessRoot = resolve(import.meta.dir, "..");
|
|
15
|
+
const testRoot = join(harnessRoot, ".tmp");
|
|
16
|
+
const temporaryDirectories: string[] = [];
|
|
17
|
+
|
|
18
|
+
async function temporaryDirectory(): Promise<string> {
|
|
19
|
+
await mkdir(testRoot, { recursive: true });
|
|
20
|
+
const directory = join(testRoot, `version-${randomUUID()}`);
|
|
21
|
+
await mkdir(directory, { recursive: true });
|
|
22
|
+
temporaryDirectories.push(directory);
|
|
23
|
+
return directory;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writePackage(directory: string, name: string, version: string): Promise<void> {
|
|
27
|
+
await mkdir(directory, { recursive: true });
|
|
28
|
+
await writeFile(join(directory, "package.json"), `${JSON.stringify({ name, version })}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function git(directory: string, args: string[]): void {
|
|
32
|
+
const result = spawnSync(
|
|
33
|
+
"git",
|
|
34
|
+
[
|
|
35
|
+
"-c",
|
|
36
|
+
"user.name=Version Test",
|
|
37
|
+
"-c",
|
|
38
|
+
"user.email=version@example.test",
|
|
39
|
+
"-c",
|
|
40
|
+
"commit.gpgsign=false",
|
|
41
|
+
...args,
|
|
42
|
+
],
|
|
43
|
+
{ cwd: directory, encoding: "utf8" },
|
|
44
|
+
);
|
|
45
|
+
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function commit(directory: string, message: string): Promise<void> {
|
|
49
|
+
await writeFile(join(directory, `${message}.txt`), `${message}\n`);
|
|
50
|
+
git(directory, ["add", "-A"]);
|
|
51
|
+
git(directory, ["commit", "-q", "-m", message]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await Promise.all(
|
|
56
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("serverVersion", () => {
|
|
61
|
+
test("reports a stale version when the installed prefix holds another version", async () => {
|
|
62
|
+
const host = await temporaryDirectory();
|
|
63
|
+
await writePackage(
|
|
64
|
+
installedPackageDir(join(host, ".devboard")),
|
|
65
|
+
"@dsdenes/devboard",
|
|
66
|
+
"0.1.9",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(serverVersion(host, "0.1.8", "0.1.9")).toEqual({
|
|
70
|
+
running: "0.1.8",
|
|
71
|
+
installed: "0.1.9",
|
|
72
|
+
latest: "0.1.9",
|
|
73
|
+
stale: true,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("reports no stale version when the prefix holds the running version", async () => {
|
|
78
|
+
const host = await temporaryDirectory();
|
|
79
|
+
await writePackage(
|
|
80
|
+
installedPackageDir(join(host, ".devboard")),
|
|
81
|
+
"@dsdenes/devboard",
|
|
82
|
+
"0.1.9",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(serverVersion(host, "0.1.9", "0.1.9")).toEqual({
|
|
86
|
+
running: "0.1.9",
|
|
87
|
+
installed: "0.1.9",
|
|
88
|
+
latest: "0.1.9",
|
|
89
|
+
stale: false,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("uses the source tree as the harness of a canonical checkout", async () => {
|
|
94
|
+
const host = await temporaryDirectory();
|
|
95
|
+
await writePackage(join(host, ".devboard"), "@dsdenes/devboard", "0.1.0");
|
|
96
|
+
|
|
97
|
+
expect(hostHarnessDir(host)).toBe(join(host, ".devboard"));
|
|
98
|
+
expect(serverVersion(host, "0.1.0")).toEqual({
|
|
99
|
+
running: "0.1.0",
|
|
100
|
+
installed: "0.1.0",
|
|
101
|
+
latest: null,
|
|
102
|
+
stale: false,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("derives an unpacked source version from the commit count", async () => {
|
|
107
|
+
const host = await temporaryDirectory();
|
|
108
|
+
git(host, ["init", "-q"]);
|
|
109
|
+
await writePackage(join(host, ".devboard"), "@dsdenes/devboard", "0.1.0");
|
|
110
|
+
await commit(host, "first");
|
|
111
|
+
|
|
112
|
+
expect(readHarnessVersion(join(host, ".devboard"))).toBe("0.1.1");
|
|
113
|
+
|
|
114
|
+
await commit(host, "second");
|
|
115
|
+
expect(readHarnessVersion(join(host, ".devboard"))).toBe("0.1.2");
|
|
116
|
+
expect(serverVersion(host, "0.1.2", "0.1.2")).toEqual({
|
|
117
|
+
running: "0.1.2",
|
|
118
|
+
installed: "0.1.2",
|
|
119
|
+
latest: "0.1.2",
|
|
120
|
+
stale: false,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("never reads a host commit count into an installed package version", async () => {
|
|
125
|
+
const host = await temporaryDirectory();
|
|
126
|
+
git(host, ["init", "-q"]);
|
|
127
|
+
await commit(host, "first");
|
|
128
|
+
const installed = installedPackageDir(join(host, ".devboard"));
|
|
129
|
+
await writePackage(installed, "@dsdenes/devboard", "0.1.0");
|
|
130
|
+
|
|
131
|
+
expect(readHarnessVersion(installed)).toBe("0.1.0");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("falls back to the running checkout for a host without a harness", async () => {
|
|
135
|
+
const host = await temporaryDirectory();
|
|
136
|
+
|
|
137
|
+
expect(hostHarnessDir(host)).toBe(runningHarnessDir());
|
|
138
|
+
expect(readHarnessVersion(host)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ServerVersion {
|
|
6
|
+
/** The harness package version this process loaded at startup. */
|
|
7
|
+
running: string | null;
|
|
8
|
+
/** The harness package version the host's prefix holds now. */
|
|
9
|
+
installed: string | null;
|
|
10
|
+
/** The newest release the update check has seen, when it has seen one. */
|
|
11
|
+
latest: string | null;
|
|
12
|
+
/** True when a restart would load different code. */
|
|
13
|
+
stale: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HARNESS_NAME = "@dsdenes/devboard";
|
|
17
|
+
/**
|
|
18
|
+
* The version the checked-in manifest records. A release writes its real
|
|
19
|
+
* version into the packed copy, so this value marks an unpacked source tree.
|
|
20
|
+
*/
|
|
21
|
+
const SOURCE_VERSION = "0.1.0";
|
|
22
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
/** The installed package a host prefix holds. */
|
|
25
|
+
export function installedPackageDir(prefix: string): string {
|
|
26
|
+
return join(prefix, "node_modules", "@dsdenes", "devboard");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The checkout that contains this server's source code. */
|
|
30
|
+
export function runningHarnessDir(): string {
|
|
31
|
+
return resolve(import.meta.dir, "..");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface HarnessManifest {
|
|
35
|
+
name: string | null;
|
|
36
|
+
version: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readManifest(directory: string): HarnessManifest | null {
|
|
40
|
+
try {
|
|
41
|
+
const parsed: unknown = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
|
42
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
43
|
+
return {
|
|
44
|
+
name: "name" in parsed && typeof parsed.name === "string" ? parsed.name : null,
|
|
45
|
+
version:
|
|
46
|
+
"version" in parsed && typeof parsed.version === "string" && parsed.version.length > 0
|
|
47
|
+
? parsed.version
|
|
48
|
+
: null,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function git(directory: string, args: string[]): string | null {
|
|
56
|
+
try {
|
|
57
|
+
const result = spawnSync("git", args, {
|
|
58
|
+
cwd: directory,
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
timeout: GIT_TIMEOUT_MS,
|
|
61
|
+
windowsHide: true,
|
|
62
|
+
});
|
|
63
|
+
if (result.error !== undefined || result.status !== 0) return null;
|
|
64
|
+
const output = result.stdout.trim();
|
|
65
|
+
return output.length > 0 ? output : null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Git roots never move while a process runs, so the lookup is cached. */
|
|
72
|
+
const gitRoots = new Map<string, string | null>();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The release version an unpacked source tree represents: `0.1.<commit-count>`,
|
|
76
|
+
* the same value the release script writes into the packed manifest. Only the
|
|
77
|
+
* canonical `<repo>/.devboard` source tree derives it; an npm prefix under a
|
|
78
|
+
* host's `node_modules` keeps the packed version, so the host's own commit
|
|
79
|
+
* count can never leak into a dashboard version.
|
|
80
|
+
*/
|
|
81
|
+
function sourceVersion(directory: string): string | null {
|
|
82
|
+
const harness = resolve(directory);
|
|
83
|
+
let root = gitRoots.get(harness);
|
|
84
|
+
if (root === undefined) {
|
|
85
|
+
const top = git(harness, ["rev-parse", "--show-toplevel"]);
|
|
86
|
+
root = top === null ? null : resolve(top);
|
|
87
|
+
gitRoots.set(harness, root);
|
|
88
|
+
}
|
|
89
|
+
if (root === null || resolve(root, ".devboard") !== harness) return null;
|
|
90
|
+
const count = git(harness, ["rev-list", "--count", "HEAD"]);
|
|
91
|
+
if (count === null || !/^[0-9]+$/.test(count)) return null;
|
|
92
|
+
const commits = Number(count);
|
|
93
|
+
return Number.isSafeInteger(commits) && commits > 0 ? `0.1.${commits}` : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readHarnessVersion(directory: string): string | null {
|
|
97
|
+
const manifest = readManifest(directory);
|
|
98
|
+
if (manifest === null) return null;
|
|
99
|
+
if (manifest.name === HARNESS_NAME && manifest.version === SOURCE_VERSION) {
|
|
100
|
+
return sourceVersion(directory) ?? manifest.version;
|
|
101
|
+
}
|
|
102
|
+
return manifest.version;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isHarnessSource(directory: string): boolean {
|
|
106
|
+
return readManifest(directory)?.name === HARNESS_NAME;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* True when the host serves a harness source checkout rather than an npm
|
|
111
|
+
* prefix: `.devboard/package.json` names the harness package and no installed
|
|
112
|
+
* copy sits in its `node_modules`. The predicate reads no repository name,
|
|
113
|
+
* remote, or other host fact, so a host that installs the harness never
|
|
114
|
+
* exposes source-checkout-only tooling.
|
|
115
|
+
*/
|
|
116
|
+
export function isHarnessSourceCheckout(repoDir: string): boolean {
|
|
117
|
+
const prefix = join(resolve(repoDir), ".devboard");
|
|
118
|
+
return isHarnessSource(prefix) && !existsSync(installedPackageDir(prefix));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The harness a host would start next. An installed host keeps the package
|
|
123
|
+
* under its npm prefix; the canonical checkout keeps its source tree. Without
|
|
124
|
+
* either, the running checkout is the only code there is.
|
|
125
|
+
*/
|
|
126
|
+
export function hostHarnessDir(repoDir: string): string {
|
|
127
|
+
const prefix = join(resolve(repoDir), ".devboard");
|
|
128
|
+
const installed = installedPackageDir(prefix);
|
|
129
|
+
if (existsSync(join(installed, "package.json"))) return installed;
|
|
130
|
+
if (isHarnessSource(prefix)) return prefix;
|
|
131
|
+
return runningHarnessDir();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Compares the version this process started with against the version the
|
|
136
|
+
* host's prefix holds now. A missing version never reports stale: the reload
|
|
137
|
+
* control only appears when a restart would change code.
|
|
138
|
+
*/
|
|
139
|
+
export function serverVersion(
|
|
140
|
+
repoDir: string,
|
|
141
|
+
running: string | null,
|
|
142
|
+
latest: string | null = null,
|
|
143
|
+
): ServerVersion {
|
|
144
|
+
const installed = readHarnessVersion(hostHarnessDir(repoDir));
|
|
145
|
+
return {
|
|
146
|
+
running,
|
|
147
|
+
installed,
|
|
148
|
+
latest,
|
|
149
|
+
stale: running !== null && installed !== null && running !== installed,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { ghCommand } from "../issues/scout/preflight";
|
|
2
|
+
import { configuredGiteaToken, giteaBaseUrl, type ForgeRemote } from "../lib/forge";
|
|
3
|
+
import {
|
|
4
|
+
giteaJson,
|
|
5
|
+
repoPath,
|
|
6
|
+
type FetchLike,
|
|
7
|
+
type GiteaTarget,
|
|
8
|
+
} from "../lib/gitea";
|
|
9
|
+
import { git, originForge } from "../issues/repository";
|
|
10
|
+
import {
|
|
11
|
+
createRepositoryGitHub,
|
|
12
|
+
shippedVersion,
|
|
13
|
+
type GitHubCommandRunner,
|
|
14
|
+
type GitHubRelease,
|
|
15
|
+
type RepositoryGitHub,
|
|
16
|
+
} from "../lib/github-release";
|
|
17
|
+
import type { WatchdogCycle } from "../watchdog/history";
|
|
18
|
+
import { readWatchdogStatus, type WatchdogIssue } from "../watchdog/status";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Issues-mode watchdog page's server half. It reads the watchdog's local
|
|
22
|
+
* facts on every request and overlays a GitHub projection that is refreshed in
|
|
23
|
+
* the background at most once per interval. A request is never held open for
|
|
24
|
+
* `gh`, and a failed read keeps the last known state and version for every
|
|
25
|
+
* issue instead of inventing one.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type WatchdogIssueState = "pending" | "implemented";
|
|
29
|
+
export type WatchdogSyncState = "ok" | "error" | "stale";
|
|
30
|
+
|
|
31
|
+
export interface WatchdogSync {
|
|
32
|
+
state: WatchdogSyncState;
|
|
33
|
+
/** The last successful GitHub read, or null before one. */
|
|
34
|
+
at: string | null;
|
|
35
|
+
/** Why the last read failed, or null after a success. */
|
|
36
|
+
error: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WatchdogIssueRow extends WatchdogIssue {
|
|
40
|
+
/** `null` until a GitHub read has answered for this issue. */
|
|
41
|
+
state: WatchdogIssueState | null;
|
|
42
|
+
/** The earliest published release that shipped the closing commit. */
|
|
43
|
+
version: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WatchdogSnapshot {
|
|
47
|
+
cycle: string | null;
|
|
48
|
+
action: string;
|
|
49
|
+
reason?: string;
|
|
50
|
+
findings: number;
|
|
51
|
+
issues: WatchdogIssueRow[];
|
|
52
|
+
pending: number;
|
|
53
|
+
cycles: WatchdogCycle[];
|
|
54
|
+
sync: WatchdogSync;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WatchdogFacts {
|
|
58
|
+
url: string;
|
|
59
|
+
state: WatchdogIssueState;
|
|
60
|
+
version: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reads every issue's GitHub facts; rejection means the read failed. */
|
|
64
|
+
export interface WatchdogReader {
|
|
65
|
+
project(urls: string[]): Promise<WatchdogFacts[]>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** At most one GitHub projection refresh starts per interval. */
|
|
69
|
+
export const WATCHDOG_REFRESH_MS = 60_000;
|
|
70
|
+
/** A success older than this, with no later failure, reads as stale. */
|
|
71
|
+
export const WATCHDOG_STALE_MS = 120_000;
|
|
72
|
+
|
|
73
|
+
export interface WatchdogActivityOptions {
|
|
74
|
+
/** `<STATE_DIR>/watchdog`. */
|
|
75
|
+
watchdogDir: string;
|
|
76
|
+
/** The checkout used by the default reader to resolve commits. */
|
|
77
|
+
repoDir?: string;
|
|
78
|
+
/** Test seam; production reads the operator's `gh`. */
|
|
79
|
+
reader?: WatchdogReader;
|
|
80
|
+
/** Test seam for the interval and stale clock. */
|
|
81
|
+
now?: () => number;
|
|
82
|
+
intervalMs?: number;
|
|
83
|
+
staleAfterMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One issue URL, split into the repository it belongs to and its number. */
|
|
87
|
+
export function issueLocator(url: string): { repository: string; number: number } | null {
|
|
88
|
+
const match =
|
|
89
|
+
/^https?:\/\/([^/?#]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/([0-9]+)(?:[/?#].*)?$/.exec(
|
|
90
|
+
url.trim(),
|
|
91
|
+
);
|
|
92
|
+
if (match === null) return null;
|
|
93
|
+
const host = (match[1] ?? "").toLowerCase();
|
|
94
|
+
if (host.includes("gitlab")) return null;
|
|
95
|
+
const number = Number(match[4]);
|
|
96
|
+
if (!Number.isInteger(number) || number <= 0) return null;
|
|
97
|
+
return { repository: `${match[2]}/${match[3]}`, number };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function firstLine(text: string): string {
|
|
101
|
+
return (
|
|
102
|
+
text
|
|
103
|
+
.split("\n")
|
|
104
|
+
.map((line) => line.trim())
|
|
105
|
+
.find((line) => line.length > 0) ?? "no diagnostic"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface WatchdogGitHubReaderOptions {
|
|
110
|
+
/** The checkout that resolves commits and release containment locally. */
|
|
111
|
+
repoDir: string;
|
|
112
|
+
environment?: Record<string, string | undefined>;
|
|
113
|
+
/** Injectable forge command; defaults to the operator's `gh`. */
|
|
114
|
+
run?: GitHubCommandRunner;
|
|
115
|
+
/** Injectable transport for the Gitea API. */
|
|
116
|
+
fetchImpl?: FetchLike;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createWatchdogGiteaReader(
|
|
120
|
+
options: WatchdogGitHubReaderOptions,
|
|
121
|
+
remote: ForgeRemote,
|
|
122
|
+
): WatchdogReader {
|
|
123
|
+
const environment = options.environment ?? process.env;
|
|
124
|
+
const baseUrl = giteaBaseUrl(remote, environment);
|
|
125
|
+
const token = configuredGiteaToken(environment);
|
|
126
|
+
|
|
127
|
+
function releaseList(value: unknown): GitHubRelease[] {
|
|
128
|
+
if (!Array.isArray(value)) return [];
|
|
129
|
+
const releases: GitHubRelease[] = [];
|
|
130
|
+
for (const entry of value) {
|
|
131
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
132
|
+
const record = entry as Record<string, unknown>;
|
|
133
|
+
if (typeof record.tag_name !== "string" || record.tag_name.length === 0) continue;
|
|
134
|
+
if (record.draft === true) continue;
|
|
135
|
+
if (typeof record.published_at !== "string" || record.published_at.length === 0) continue;
|
|
136
|
+
releases.push({ tag: record.tag_name, publishedAt: record.published_at });
|
|
137
|
+
}
|
|
138
|
+
return releases;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
async project(urls: string[]): Promise<WatchdogFacts[]> {
|
|
143
|
+
if (baseUrl === undefined || baseUrl.length === 0) {
|
|
144
|
+
throw new Error("the watchdog activity needs DEVBOARD_GITEA_URL for a Gitea origin");
|
|
145
|
+
}
|
|
146
|
+
const facts: WatchdogFacts[] = [];
|
|
147
|
+
for (const url of urls) {
|
|
148
|
+
const locator = issueLocator(url);
|
|
149
|
+
if (locator === null) throw new Error(`not a supported issue URL: ${url}`);
|
|
150
|
+
const target: GiteaTarget = {
|
|
151
|
+
baseUrl,
|
|
152
|
+
project: locator.repository,
|
|
153
|
+
...(token === undefined ? {} : { token }),
|
|
154
|
+
};
|
|
155
|
+
const issue = await giteaJson<unknown>(
|
|
156
|
+
target,
|
|
157
|
+
`${repoPath(target)}/issues/${locator.number}`,
|
|
158
|
+
undefined,
|
|
159
|
+
options.fetchImpl,
|
|
160
|
+
);
|
|
161
|
+
const state =
|
|
162
|
+
typeof issue === "object" && issue !== null && "state" in issue
|
|
163
|
+
? issue.state
|
|
164
|
+
: undefined;
|
|
165
|
+
if (state !== "closed") {
|
|
166
|
+
facts.push({ url, state: "pending", version: null });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const releases = releaseList(
|
|
170
|
+
await giteaJson<unknown>(
|
|
171
|
+
target,
|
|
172
|
+
`${repoPath(target)}/releases?limit=100`,
|
|
173
|
+
undefined,
|
|
174
|
+
options.fetchImpl,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
const client = {
|
|
178
|
+
async closingCommit(issueNumber: number): Promise<string | null> {
|
|
179
|
+
const timeline = await giteaJson<unknown>(
|
|
180
|
+
target,
|
|
181
|
+
`${repoPath(target)}/issues/${issueNumber}/timeline`,
|
|
182
|
+
undefined,
|
|
183
|
+
options.fetchImpl,
|
|
184
|
+
);
|
|
185
|
+
if (!Array.isArray(timeline)) return null;
|
|
186
|
+
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
|
187
|
+
const entry = timeline[index];
|
|
188
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
189
|
+
const record = entry as Record<string, unknown>;
|
|
190
|
+
if (typeof record.commit_id === "string" && /^[0-9a-f]{7,40}$/.test(record.commit_id)) {
|
|
191
|
+
return record.commit_id;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
},
|
|
196
|
+
async resolveCommit(short: string): Promise<string | null> {
|
|
197
|
+
if (!/^[0-9a-fA-F]{7,40}$/.test(short)) return null;
|
|
198
|
+
const local = git(options.repoDir, "rev-parse", "--verify", "--quiet", `${short}^{commit}`);
|
|
199
|
+
if (local.code !== 0) return null;
|
|
200
|
+
const sha = local.stdout.split(/\s+/)[0] ?? "";
|
|
201
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
202
|
+
},
|
|
203
|
+
async containingReleaseTags(
|
|
204
|
+
commit: string,
|
|
205
|
+
published: GitHubRelease[],
|
|
206
|
+
): Promise<string[]> {
|
|
207
|
+
const local = git(options.repoDir, "tag", "--contains", commit);
|
|
208
|
+
if (local.code !== 0) return [];
|
|
209
|
+
const tags = new Set(
|
|
210
|
+
local.stdout.split("\n").map((line) => line.trim()).filter(Boolean),
|
|
211
|
+
);
|
|
212
|
+
return published.filter((release) => tags.has(release.tag)).map((release) => release.tag);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const version = await shippedVersion(
|
|
216
|
+
{ number: locator.number, state: "closed" },
|
|
217
|
+
client,
|
|
218
|
+
releases,
|
|
219
|
+
);
|
|
220
|
+
facts.push({ url, state: "implemented", version });
|
|
221
|
+
}
|
|
222
|
+
return facts;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The live reader. Each issue's repository is taken from its own URL, so the
|
|
229
|
+
* page stays correct when the ledger names issues outside the host origin.
|
|
230
|
+
*/
|
|
231
|
+
export function createWatchdogGitHubReader(
|
|
232
|
+
options: WatchdogGitHubReaderOptions,
|
|
233
|
+
): WatchdogReader {
|
|
234
|
+
const remote = originForge(options.repoDir, options.environment);
|
|
235
|
+
if (remote?.forge === "gitea") return createWatchdogGiteaReader(options, remote);
|
|
236
|
+
const run = options.run ?? ((argv, environment) => ghCommand(argv, environment));
|
|
237
|
+
const clients = new Map<string, RepositoryGitHub>();
|
|
238
|
+
|
|
239
|
+
function client(repository: string): RepositoryGitHub {
|
|
240
|
+
const existing = clients.get(repository);
|
|
241
|
+
if (existing !== undefined) return existing;
|
|
242
|
+
const created = createRepositoryGitHub({
|
|
243
|
+
repository,
|
|
244
|
+
repoDir: options.repoDir,
|
|
245
|
+
run,
|
|
246
|
+
...(options.environment === undefined ? {} : { environment: options.environment }),
|
|
247
|
+
});
|
|
248
|
+
clients.set(repository, created);
|
|
249
|
+
return created;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
async project(urls: string[]): Promise<WatchdogFacts[]> {
|
|
254
|
+
const releases = new Map<string, GitHubRelease[]>();
|
|
255
|
+
const facts: WatchdogFacts[] = [];
|
|
256
|
+
for (const url of urls) {
|
|
257
|
+
const locator = issueLocator(url);
|
|
258
|
+
if (locator === null) {
|
|
259
|
+
throw new Error(`not a GitHub issue URL: ${url}`);
|
|
260
|
+
}
|
|
261
|
+
const github = client(locator.repository);
|
|
262
|
+
const state = await github.issueState(locator.number);
|
|
263
|
+
if (state === "open") {
|
|
264
|
+
facts.push({ url, state: "pending", version: null });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
let published = releases.get(locator.repository);
|
|
268
|
+
if (published === undefined) {
|
|
269
|
+
published = await github.listReleases();
|
|
270
|
+
releases.set(locator.repository, published);
|
|
271
|
+
}
|
|
272
|
+
const version = await shippedVersion(
|
|
273
|
+
{ number: locator.number, state: "closed" },
|
|
274
|
+
github,
|
|
275
|
+
published,
|
|
276
|
+
);
|
|
277
|
+
facts.push({ url, state: "implemented", version });
|
|
278
|
+
}
|
|
279
|
+
return facts;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The page's projection. `view()` answers immediately from the last read and
|
|
286
|
+
* schedules a refresh when one is due; `settled()` lets a test await that
|
|
287
|
+
* refresh without the endpoint ever waiting for it.
|
|
288
|
+
*/
|
|
289
|
+
export class WatchdogActivity {
|
|
290
|
+
private readonly watchdogDir: string;
|
|
291
|
+
private readonly reader: WatchdogReader;
|
|
292
|
+
private readonly now: () => number;
|
|
293
|
+
private readonly intervalMs: number;
|
|
294
|
+
private readonly staleAfterMs: number;
|
|
295
|
+
private readonly facts = new Map<string, { state: WatchdogIssueState; version: string | null }>();
|
|
296
|
+
private lastStarted: number | null = null;
|
|
297
|
+
private lastSuccess: number | null = null;
|
|
298
|
+
private lastFailure: string | null = null;
|
|
299
|
+
private inFlight: Promise<void> | null = null;
|
|
300
|
+
|
|
301
|
+
constructor(options: WatchdogActivityOptions) {
|
|
302
|
+
this.watchdogDir = options.watchdogDir;
|
|
303
|
+
this.reader =
|
|
304
|
+
options.reader ??
|
|
305
|
+
createWatchdogGitHubReader({ repoDir: options.repoDir ?? process.cwd() });
|
|
306
|
+
this.now = options.now ?? (() => Date.now());
|
|
307
|
+
this.intervalMs = Math.max(1, options.intervalMs ?? WATCHDOG_REFRESH_MS);
|
|
308
|
+
this.staleAfterMs = Math.max(0, options.staleAfterMs ?? WATCHDOG_STALE_MS);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The current projection; also the only trigger for a refresh. */
|
|
312
|
+
view(): WatchdogSnapshot {
|
|
313
|
+
this.schedule();
|
|
314
|
+
return this.build();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Awaits the refresh a `view()` scheduled, if any. */
|
|
318
|
+
settled(): Promise<void> {
|
|
319
|
+
return this.inFlight ?? Promise.resolve();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private schedule(): void {
|
|
323
|
+
if (this.inFlight !== null) return;
|
|
324
|
+
const now = this.now();
|
|
325
|
+
if (this.lastStarted !== null && now - this.lastStarted < this.intervalMs) return;
|
|
326
|
+
this.lastStarted = now;
|
|
327
|
+
this.inFlight = this.refresh().finally(() => {
|
|
328
|
+
this.inFlight = null;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async refresh(): Promise<void> {
|
|
333
|
+
try {
|
|
334
|
+
const urls = readWatchdogStatus(this.watchdogDir).issues.map((issue) => issue.url);
|
|
335
|
+
const facts = urls.length === 0 ? [] : await this.reader.project(urls);
|
|
336
|
+
const next = new Map<string, { state: WatchdogIssueState; version: string | null }>();
|
|
337
|
+
for (const fact of facts) {
|
|
338
|
+
const previous = this.facts.get(fact.url);
|
|
339
|
+
const version =
|
|
340
|
+
fact.state === "implemented"
|
|
341
|
+
? fact.version ?? (previous?.state === "implemented" ? previous.version : null)
|
|
342
|
+
: null;
|
|
343
|
+
next.set(fact.url, { state: fact.state, version });
|
|
344
|
+
}
|
|
345
|
+
this.facts.clear();
|
|
346
|
+
for (const [url, value] of next) this.facts.set(url, value);
|
|
347
|
+
this.lastSuccess = this.now();
|
|
348
|
+
this.lastFailure = null;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
351
|
+
this.lastFailure = firstLine(reason);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private sync(): WatchdogSync {
|
|
356
|
+
const now = this.now();
|
|
357
|
+
const at = this.lastSuccess === null ? null : new Date(this.lastSuccess).toISOString();
|
|
358
|
+
if (this.lastFailure !== null) {
|
|
359
|
+
return { state: "error", at, error: this.lastFailure };
|
|
360
|
+
}
|
|
361
|
+
if (this.lastSuccess === null) return { state: "stale", at: null, error: null };
|
|
362
|
+
if (now - this.lastSuccess > this.staleAfterMs) {
|
|
363
|
+
return { state: "stale", at, error: null };
|
|
364
|
+
}
|
|
365
|
+
return { state: "ok", at, error: null };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private build(): WatchdogSnapshot {
|
|
369
|
+
const status = readWatchdogStatus(this.watchdogDir);
|
|
370
|
+
const issues: WatchdogIssueRow[] = status.issues.map((issue) => {
|
|
371
|
+
const known = this.facts.get(issue.url);
|
|
372
|
+
return {
|
|
373
|
+
...issue,
|
|
374
|
+
state: known?.state ?? null,
|
|
375
|
+
version: known?.state === "implemented" ? known.version : null,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
return {
|
|
379
|
+
cycle: status.cycle,
|
|
380
|
+
action: status.action,
|
|
381
|
+
...(status.reason === undefined ? {} : { reason: status.reason }),
|
|
382
|
+
findings: status.findings,
|
|
383
|
+
issues,
|
|
384
|
+
pending: status.pending,
|
|
385
|
+
cycles: status.cycles,
|
|
386
|
+
sync: this.sync(),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|