@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,318 @@
|
|
|
1
|
+
import { git } from "../issues/repository";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub release resolution shared by Auto Scout and the watchdog activity
|
|
5
|
+
* page. One repository-scoped reader lists published releases, finds the
|
|
6
|
+
* commit that closed an issue, and answers which release shipped it. Both
|
|
7
|
+
* surfaces call this module, so a change to the rules changes both at once.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface GitHubRelease {
|
|
11
|
+
tag: string;
|
|
12
|
+
/** ISO publication time; only published releases are listed. */
|
|
13
|
+
publishedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GitHubCommandOutcome {
|
|
17
|
+
code: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type GitHubCommandRunner = (
|
|
23
|
+
argv: string[],
|
|
24
|
+
environment: Record<string, string>,
|
|
25
|
+
) => Promise<GitHubCommandOutcome>;
|
|
26
|
+
|
|
27
|
+
export interface RepositoryGitHubOptions {
|
|
28
|
+
/** `owner/name` on github.com. */
|
|
29
|
+
repository: string;
|
|
30
|
+
/** The checkout used to resolve a short commit and contain release tags. */
|
|
31
|
+
repoDir: string;
|
|
32
|
+
/** The forge command, already bound to its credential. */
|
|
33
|
+
run: GitHubCommandRunner;
|
|
34
|
+
environment?: Record<string, string | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RepositoryGitHub {
|
|
38
|
+
/** Every published release, drafts excluded. */
|
|
39
|
+
listReleases(): Promise<GitHubRelease[]>;
|
|
40
|
+
/** The issue's state as GitHub reports it. */
|
|
41
|
+
issueState(issueNumber: number): Promise<"open" | "closed">;
|
|
42
|
+
/** The commit that closed an issue, from its timeline or newest comment. */
|
|
43
|
+
closingCommit(issueNumber: number): Promise<string | null>;
|
|
44
|
+
/** One unambiguous full commit, through the checkout or GitHub, or null. */
|
|
45
|
+
resolveCommit(short: string): Promise<string | null>;
|
|
46
|
+
/** The published release tags whose history contains `commit`. */
|
|
47
|
+
containingReleaseTags(commit: string, releases: GitHubRelease[]): Promise<string[]>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SHA = /^[0-9a-fA-F]{7,40}$/;
|
|
51
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
52
|
+
const SHA_TOKEN = /\b[0-9a-fA-F]{7,40}\b/g;
|
|
53
|
+
|
|
54
|
+
export function isSha(value: unknown): value is string {
|
|
55
|
+
return typeof value === "string" && SHA.test(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isFullSha(value: unknown): value is string {
|
|
59
|
+
return typeof value === "string" && FULL_SHA.test(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every 7–40 character hex token in a comment, in the order it appears. */
|
|
63
|
+
export function shaTokens(text: string): string[] {
|
|
64
|
+
return [...text.matchAll(SHA_TOKEN)].map((match) => match[0]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The newest comment that carries a SHA yields its last SHA; else null. */
|
|
68
|
+
export function closingCommitFromComments(comments: string[]): string | null {
|
|
69
|
+
for (let index = comments.length - 1; index >= 0; index -= 1) {
|
|
70
|
+
const tokens = shaTokens(comments[index] ?? "");
|
|
71
|
+
if (tokens.length > 0) return tokens[tokens.length - 1]!;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The timeline's closing commit when GitHub reports one. The newest closing
|
|
78
|
+
* event wins, because a reopened and re-closed issue has several.
|
|
79
|
+
*/
|
|
80
|
+
export function closingCommitFromTimeline(
|
|
81
|
+
events: Array<{ event?: unknown; commit_id?: unknown }>,
|
|
82
|
+
): string | null {
|
|
83
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
const event = events[index];
|
|
85
|
+
if (event?.event !== "closed") continue;
|
|
86
|
+
if (isSha(event.commit_id)) return event.commit_id;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parses a JSON array `gh` printed, refusing a shape it does not know. */
|
|
92
|
+
export function parseJsonArray(text: string): unknown[] | undefined {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(text);
|
|
95
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parses one JSON object per line, as `gh api --jq` emits it. */
|
|
102
|
+
export function parseJsonLines(text: string): unknown[] {
|
|
103
|
+
const values: unknown[] = [];
|
|
104
|
+
for (const line of text.split("\n")) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (trimmed.length === 0) continue;
|
|
107
|
+
try {
|
|
108
|
+
values.push(JSON.parse(trimmed));
|
|
109
|
+
} catch {
|
|
110
|
+
// A line that is not JSON is not part of the contract.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return values;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The earliest published release whose tag is in `tags`, ordered by
|
|
118
|
+
* publication time and breaking a tie by tag name.
|
|
119
|
+
*/
|
|
120
|
+
export function earliestRelease(releases: GitHubRelease[], tags: string[]): string | null {
|
|
121
|
+
const contained = new Set(tags);
|
|
122
|
+
const candidates = releases.filter((release) => contained.has(release.tag));
|
|
123
|
+
candidates.sort((left, right) => {
|
|
124
|
+
const leftAt = Date.parse(left.publishedAt);
|
|
125
|
+
const rightAt = Date.parse(right.publishedAt);
|
|
126
|
+
const leftValid = Number.isFinite(leftAt);
|
|
127
|
+
const rightValid = Number.isFinite(rightAt);
|
|
128
|
+
if (leftValid && rightValid && leftAt !== rightAt) return leftAt - rightAt;
|
|
129
|
+
if (leftValid !== rightValid) return leftValid ? -1 : 1;
|
|
130
|
+
return left.tag < right.tag ? -1 : left.tag > right.tag ? 1 : 0;
|
|
131
|
+
});
|
|
132
|
+
return candidates[0]?.tag ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ClosingCommitIssue {
|
|
136
|
+
number: number;
|
|
137
|
+
state: "open" | "closed";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ClosingCommitClient {
|
|
141
|
+
closingCommit(issueNumber: number): Promise<string | null>;
|
|
142
|
+
resolveCommit(short: string): Promise<string | null>;
|
|
143
|
+
containingReleaseTags(commit: string, releases: GitHubRelease[]): Promise<string[]>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The release that shipped the issue's closing commit: the timeline's commit
|
|
148
|
+
* when present, otherwise the newest comment's SHA, resolved to one full
|
|
149
|
+
* commit, then matched against the earliest published release containing it.
|
|
150
|
+
*/
|
|
151
|
+
export async function shippedVersion<Issue extends ClosingCommitIssue>(
|
|
152
|
+
issue: Issue,
|
|
153
|
+
client: ClosingCommitClient,
|
|
154
|
+
releases: GitHubRelease[],
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
if (issue.state !== "closed") return null;
|
|
157
|
+
try {
|
|
158
|
+
const candidate = await client.closingCommit(issue.number);
|
|
159
|
+
if (candidate === null) return null;
|
|
160
|
+
const commit = await client.resolveCommit(candidate);
|
|
161
|
+
if (commit === null) return null;
|
|
162
|
+
const tags = await client.containingReleaseTags(commit, releases);
|
|
163
|
+
return earliestRelease(releases, tags);
|
|
164
|
+
} catch {
|
|
165
|
+
// Resolution is retried on the next periodic read.
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function firstLine(text: string): string {
|
|
171
|
+
return (
|
|
172
|
+
text
|
|
173
|
+
.split("\n")
|
|
174
|
+
.map((line) => line.trim())
|
|
175
|
+
.find((line) => line.length > 0) ?? "no diagnostic"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One repository's GitHub reads. Every request carries the repository, so a
|
|
181
|
+
* surface that watches several repositories owns one reader per repository.
|
|
182
|
+
*/
|
|
183
|
+
export function createRepositoryGitHub(options: RepositoryGitHubOptions): RepositoryGitHub {
|
|
184
|
+
const repository = options.repository;
|
|
185
|
+
const environment: Record<string, string> = {};
|
|
186
|
+
for (const [key, value] of Object.entries(options.environment ?? process.env)) {
|
|
187
|
+
if (value !== undefined) environment[key] = value;
|
|
188
|
+
}
|
|
189
|
+
const run = options.run;
|
|
190
|
+
|
|
191
|
+
async function read(argv: string[]): Promise<string> {
|
|
192
|
+
const result = await run(argv, environment);
|
|
193
|
+
if (result.code !== 0) throw new Error(firstLine(result.stderr));
|
|
194
|
+
return result.stdout;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function api(endpoint: string, jq: string): Promise<string> {
|
|
198
|
+
return await read(["api", `repos/${repository}/${endpoint}`, "--paginate", "--jq", jq]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
async listReleases(): Promise<GitHubRelease[]> {
|
|
203
|
+
const text = await read([
|
|
204
|
+
"api",
|
|
205
|
+
`repos/${repository}/releases`,
|
|
206
|
+
"--paginate",
|
|
207
|
+
"--jq",
|
|
208
|
+
".[] | {tag: .tag_name, publishedAt: .published_at, draft: .draft}",
|
|
209
|
+
]);
|
|
210
|
+
const releases: GitHubRelease[] = [];
|
|
211
|
+
for (const entry of parseJsonLines(text)) {
|
|
212
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
213
|
+
const record = entry as Record<string, unknown>;
|
|
214
|
+
if (typeof record.tag !== "string" || record.tag.length === 0) continue;
|
|
215
|
+
if (record.draft === true) continue;
|
|
216
|
+
if (typeof record.publishedAt !== "string" || record.publishedAt.length === 0) continue;
|
|
217
|
+
releases.push({ tag: record.tag, publishedAt: record.publishedAt });
|
|
218
|
+
}
|
|
219
|
+
return releases;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async issueState(issueNumber: number): Promise<"open" | "closed"> {
|
|
223
|
+
const text = await read([
|
|
224
|
+
"api",
|
|
225
|
+
`repos/${repository}/issues/${issueNumber}`,
|
|
226
|
+
"--jq",
|
|
227
|
+
".state",
|
|
228
|
+
]);
|
|
229
|
+
const state = text.trim().toLowerCase();
|
|
230
|
+
if (state === "open" || state === "closed") return state;
|
|
231
|
+
throw new Error(`gh reported an unknown issue state for ${repository}#${issueNumber}`);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async closingCommit(issueNumber: number): Promise<string | null> {
|
|
235
|
+
let timelineFailure: string | null = null;
|
|
236
|
+
try {
|
|
237
|
+
const text = await api(
|
|
238
|
+
`issues/${issueNumber}/timeline`,
|
|
239
|
+
'.[] | select(.event == "closed" and .commit_id != null) | .commit_id',
|
|
240
|
+
);
|
|
241
|
+
const commits = text
|
|
242
|
+
.split("\n")
|
|
243
|
+
.map((line) => line.trim())
|
|
244
|
+
.filter((line) => isSha(line));
|
|
245
|
+
const timeline = closingCommitFromTimeline(
|
|
246
|
+
commits.map((commit) => ({ event: "closed", commit_id: commit })),
|
|
247
|
+
);
|
|
248
|
+
if (timeline !== null) return timeline;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
timelineFailure = error instanceof Error ? error.message : String(error);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const text = await api(`issues/${issueNumber}/comments`, ".[] | {body: .body}");
|
|
255
|
+
const comments: string[] = [];
|
|
256
|
+
for (const entry of parseJsonLines(text)) {
|
|
257
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
258
|
+
const body = (entry as Record<string, unknown>).body;
|
|
259
|
+
if (typeof body === "string") comments.push(body);
|
|
260
|
+
}
|
|
261
|
+
return closingCommitFromComments(comments);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
264
|
+
throw new Error(timelineFailure === null ? detail : `${timelineFailure}; ${detail}`);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async resolveCommit(short: string): Promise<string | null> {
|
|
269
|
+
if (!isSha(short)) return null;
|
|
270
|
+
const local = git(options.repoDir, "rev-parse", "--verify", "--quiet", `${short}^{commit}`);
|
|
271
|
+
if (local.code === 0) {
|
|
272
|
+
const sha = local.stdout.split(/\s+/)[0] ?? "";
|
|
273
|
+
if (isFullSha(sha)) return sha;
|
|
274
|
+
}
|
|
275
|
+
const result = await run(
|
|
276
|
+
["api", `repos/${repository}/commits/${short}`, "--jq", ".sha"],
|
|
277
|
+
environment,
|
|
278
|
+
);
|
|
279
|
+
if (result.code !== 0) return null;
|
|
280
|
+
const sha = result.stdout.trim();
|
|
281
|
+
return isFullSha(sha) ? sha : null;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async containingReleaseTags(commit: string, releases: GitHubRelease[]): Promise<string[]> {
|
|
285
|
+
const local = git(options.repoDir, "tag", "--contains", commit);
|
|
286
|
+
if (local.code === 0) {
|
|
287
|
+
const tags = new Set(
|
|
288
|
+
local.stdout
|
|
289
|
+
.split("\n")
|
|
290
|
+
.map((line) => line.trim())
|
|
291
|
+
.filter((line) => line.length > 0),
|
|
292
|
+
);
|
|
293
|
+
if (tags.size > 0) {
|
|
294
|
+
const contained = releases
|
|
295
|
+
.filter((release) => tags.has(release.tag))
|
|
296
|
+
.map((release) => release.tag);
|
|
297
|
+
if (contained.length > 0) return contained;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const contained: string[] = [];
|
|
301
|
+
for (const release of releases) {
|
|
302
|
+
const result = await run(
|
|
303
|
+
[
|
|
304
|
+
"api",
|
|
305
|
+
`repos/${repository}/compare/${commit}...${encodeURIComponent(release.tag)}`,
|
|
306
|
+
"--jq",
|
|
307
|
+
".status",
|
|
308
|
+
],
|
|
309
|
+
environment,
|
|
310
|
+
);
|
|
311
|
+
if (result.code !== 0) continue;
|
|
312
|
+
const status = result.stdout.trim();
|
|
313
|
+
if (status === "ahead" || status === "identical") contained.push(release.tag);
|
|
314
|
+
}
|
|
315
|
+
return contained;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { dashboardInventory, describeInventory } from "./inventory";
|
|
6
|
+
|
|
7
|
+
const harnessRoot = resolve(import.meta.dir, "..");
|
|
8
|
+
const testRoot = join(harnessRoot, ".tmp");
|
|
9
|
+
const temporaryDirectories: string[] = [];
|
|
10
|
+
|
|
11
|
+
async function temporaryDirectory(): Promise<string> {
|
|
12
|
+
await mkdir(testRoot, { recursive: true });
|
|
13
|
+
const directory = join(testRoot, `inventory-${randomUUID()}`);
|
|
14
|
+
await mkdir(directory, { recursive: true });
|
|
15
|
+
temporaryDirectories.push(directory);
|
|
16
|
+
return directory;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function writePackage(directory: string, version: string): Promise<void> {
|
|
20
|
+
await mkdir(directory, { recursive: true });
|
|
21
|
+
await writeFile(join(directory, "package.json"), `${JSON.stringify({ version })}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await Promise.all(
|
|
26
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("dashboardInventory", () => {
|
|
31
|
+
test("reports the loaded package and the package the host prefix holds", async () => {
|
|
32
|
+
const host = await temporaryDirectory();
|
|
33
|
+
const running = join(host, "running");
|
|
34
|
+
await writePackage(running, "0.1.7");
|
|
35
|
+
await writePackage(
|
|
36
|
+
join(host, ".devboard", "node_modules", "@dsdenes", "devboard"),
|
|
37
|
+
"0.1.8",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
dashboardInventory([{ pid: 41, port: 8084, repoDir: host, harness: running }]),
|
|
42
|
+
).toEqual([
|
|
43
|
+
{
|
|
44
|
+
host,
|
|
45
|
+
port: 8084,
|
|
46
|
+
pid: 41,
|
|
47
|
+
running: "0.1.7",
|
|
48
|
+
installed: "0.1.8",
|
|
49
|
+
stale: true,
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("reports a current dashboard without a stale flag", async () => {
|
|
55
|
+
const host = await temporaryDirectory();
|
|
56
|
+
const prefix = join(host, ".devboard", "node_modules", "@dsdenes", "devboard");
|
|
57
|
+
await writePackage(prefix, "0.1.8");
|
|
58
|
+
|
|
59
|
+
const [entry] = dashboardInventory([{ pid: 42, port: 8085, repoDir: host, harness: prefix }]);
|
|
60
|
+
expect(entry?.stale).toBe(false);
|
|
61
|
+
expect(describeInventory(entry!)).toBe(
|
|
62
|
+
`publish: ${host} port 8085 (pid 42) running 0.1.8 installed 0.1.8`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("describes a stale dashboard with the literal stale marker", async () => {
|
|
67
|
+
const host = await temporaryDirectory();
|
|
68
|
+
const running = join(host, "running");
|
|
69
|
+
await writePackage(running, "0.1.7");
|
|
70
|
+
await writePackage(
|
|
71
|
+
join(host, ".devboard", "node_modules", "@dsdenes", "devboard"),
|
|
72
|
+
"0.1.8",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const [entry] = dashboardInventory([{ pid: 43, port: 8086, repoDir: host, harness: running }]);
|
|
76
|
+
expect(describeInventory(entry!)).toContain("stale");
|
|
77
|
+
expect(describeInventory(entry!)).toContain("running 0.1.7 installed 0.1.8");
|
|
78
|
+
});
|
|
79
|
+
});
|
package/lib/inventory.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readHarnessVersion } from "../server/version";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a running dashboard serves, as an operator needs it: the package it
|
|
6
|
+
* loaded, the package its host prefix now holds, and whether a restart would
|
|
7
|
+
* change code. Publish reports this and never restarts anything.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface DashboardInstance {
|
|
11
|
+
pid: number;
|
|
12
|
+
port: number;
|
|
13
|
+
repoDir: string;
|
|
14
|
+
/** Harness directory the process runs from. */
|
|
15
|
+
harness: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DashboardInventory {
|
|
19
|
+
host: string;
|
|
20
|
+
port: number;
|
|
21
|
+
pid: number;
|
|
22
|
+
running: string | null;
|
|
23
|
+
installed: string | null;
|
|
24
|
+
stale: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function dashboardInventory(instances: DashboardInstance[]): DashboardInventory[] {
|
|
28
|
+
return instances.map((instance) => {
|
|
29
|
+
const running = readHarnessVersion(instance.harness);
|
|
30
|
+
const installed =
|
|
31
|
+
instance.repoDir.length === 0
|
|
32
|
+
? running
|
|
33
|
+
: readHarnessVersion(
|
|
34
|
+
join(instance.repoDir, ".devboard", "node_modules", "@dsdenes", "devboard"),
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
host: instance.repoDir.length > 0 ? instance.repoDir : "an unknown host",
|
|
38
|
+
port: instance.port,
|
|
39
|
+
pid: instance.pid,
|
|
40
|
+
running,
|
|
41
|
+
installed,
|
|
42
|
+
stale: running !== null && installed !== null && running !== installed,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function describeInventory(entry: DashboardInventory): string {
|
|
48
|
+
return (
|
|
49
|
+
`publish: ${entry.host} port ${entry.port} (pid ${entry.pid}) ` +
|
|
50
|
+
`running ${entry.running ?? "unknown"} installed ${entry.installed ?? "unknown"}` +
|
|
51
|
+
(entry.stale ? " stale" : "")
|
|
52
|
+
);
|
|
53
|
+
}
|