forgedock 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
package/bin/forgedock.mjs
CHANGED
|
@@ -35,6 +35,17 @@ import {
|
|
|
35
35
|
persistHome,
|
|
36
36
|
PIPELINE_SCRIPTS,
|
|
37
37
|
} from "./journey.mjs";
|
|
38
|
+
import {
|
|
39
|
+
deriveWorkflowLabels,
|
|
40
|
+
FINDINGS_LANE_LABEL,
|
|
41
|
+
classifyFindingStatus,
|
|
42
|
+
hasWorkflowLabel,
|
|
43
|
+
normalizePriority,
|
|
44
|
+
parseSatelliteRepos,
|
|
45
|
+
buildHeartbeatBatchQuery,
|
|
46
|
+
parseHeartbeatBatchResponse,
|
|
47
|
+
chunkArray,
|
|
48
|
+
} from "./watch-utils.mjs";
|
|
38
49
|
import {
|
|
39
50
|
removeSessionStartHook,
|
|
40
51
|
removeSubagentStopHook,
|
|
@@ -1476,36 +1487,84 @@ async function printVersionAvailableChangelog(localVersion, latestVersion) {
|
|
|
1476
1487
|
}
|
|
1477
1488
|
|
|
1478
1489
|
/**
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
* where there is nothing durable to reinstall over.
|
|
1490
|
+
* Resolve the absolute path npm's global install tree would use for a
|
|
1491
|
+
* `forgedock` package, i.e. `{npm root -g}/forgedock`. Shared by
|
|
1492
|
+
* `isGlobalNpmInstall()` and `findSeparateGlobalInstall()` (forge#2273) so
|
|
1493
|
+
* the `npm root -g` shell-out and path-join logic exists in exactly one
|
|
1494
|
+
* place instead of being duplicated across both callers.
|
|
1485
1495
|
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
* behavior is always a safe fallback when detection is inconclusive.
|
|
1496
|
+
* Fails closed (returns null) on any error, timeout, or empty `npm root -g`
|
|
1497
|
+
* output — callers treat null identically to their pre-existing "detection
|
|
1498
|
+
* inconclusive" fallback (isGlobalNpmInstall: false; findSeparateGlobalInstall: null).
|
|
1490
1499
|
*
|
|
1491
|
-
* @returns {
|
|
1500
|
+
* @returns {string|null} absolute path to `{globalRoot}/forgedock`, or null
|
|
1492
1501
|
*/
|
|
1493
|
-
function
|
|
1502
|
+
function resolveGlobalNpmForgedockDir() {
|
|
1494
1503
|
try {
|
|
1495
1504
|
const globalRoot = execSync("npm root -g", {
|
|
1496
1505
|
encoding: "utf-8",
|
|
1497
1506
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1498
1507
|
timeout: 5000,
|
|
1499
1508
|
}).trim();
|
|
1500
|
-
if (!globalRoot) return
|
|
1501
|
-
|
|
1502
|
-
const home = resolve(FORGE_HOME);
|
|
1503
|
-
return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
|
|
1509
|
+
if (!globalRoot) return null;
|
|
1510
|
+
return resolve(join(globalRoot, "forgedock"));
|
|
1504
1511
|
} catch {
|
|
1505
|
-
return
|
|
1512
|
+
return null;
|
|
1506
1513
|
}
|
|
1507
1514
|
}
|
|
1508
1515
|
|
|
1516
|
+
/**
|
|
1517
|
+
* Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
|
|
1518
|
+
* this process is running as a globally-installed `forgedock` package (not
|
|
1519
|
+
* an ephemeral npx/dlx cache extraction). Used by update()'s npm-mode branch
|
|
1520
|
+
* (forge#2133) to decide whether a newer published version can be installed
|
|
1521
|
+
* in place with `npm install -g forgedock@latest`, versus the npx-cache case
|
|
1522
|
+
* where there is nothing durable to reinstall over.
|
|
1523
|
+
*
|
|
1524
|
+
* Resolution: `resolveGlobalNpmForgedockDir()` (forge#2273) reports where npm's
|
|
1525
|
+
* global install tree would place a `forgedock` package; if FORGE_HOME sits
|
|
1526
|
+
* under that path, this is a global install. Fails closed (returns false) on
|
|
1527
|
+
* any error — the pre-existing advisory-only behavior is always a safe
|
|
1528
|
+
* fallback when detection is inconclusive.
|
|
1529
|
+
*
|
|
1530
|
+
* @returns {boolean}
|
|
1531
|
+
*/
|
|
1532
|
+
function isGlobalNpmInstall() {
|
|
1533
|
+
const globalPkgDir = resolveGlobalNpmForgedockDir();
|
|
1534
|
+
if (!globalPkgDir) return false;
|
|
1535
|
+
const home = resolve(FORGE_HOME);
|
|
1536
|
+
return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Detect a separate global npm install of `forgedock` that is NOT the
|
|
1541
|
+
* `FORGE_HOME` this process resolved to (forge#2220). This is the classic
|
|
1542
|
+
* `npx`-shadowing scenario: running `npx forgedock update` from inside a
|
|
1543
|
+
* ForgeDock git clone resolves `FORGE_HOME` to the clone (because the clone
|
|
1544
|
+
* itself is the `forgedock` package on disk), so the git-clone update path
|
|
1545
|
+
* runs and updates the checkout — while a genuinely separate global npm
|
|
1546
|
+
* install elsewhere on disk is silently left untouched.
|
|
1547
|
+
*
|
|
1548
|
+
* Resolution mirrors `isGlobalNpmInstall()` via the shared
|
|
1549
|
+
* `resolveGlobalNpmForgedockDir()` helper (forge#2273): if a `forgedock`
|
|
1550
|
+
* package exists at the resolved global path AND it is a different path than
|
|
1551
|
+
* the current `FORGE_HOME`, a separate global install exists. Fails closed
|
|
1552
|
+
* (returns null) on any error or timeout — this is an advisory notice only
|
|
1553
|
+
* and must never block or alter the update itself.
|
|
1554
|
+
*
|
|
1555
|
+
* @returns {string|null} absolute path to the separate global install, or null
|
|
1556
|
+
*/
|
|
1557
|
+
function findSeparateGlobalInstall() {
|
|
1558
|
+
const globalPkgDir = resolveGlobalNpmForgedockDir();
|
|
1559
|
+
if (!globalPkgDir) return null;
|
|
1560
|
+
const home = resolve(FORGE_HOME);
|
|
1561
|
+
if (home === globalPkgDir || home.startsWith(globalPkgDir + sep)) {
|
|
1562
|
+
// Current FORGE_HOME already IS the global install — nothing separate.
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
return existsSync(globalPkgDir) ? globalPkgDir : null;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1509
1568
|
/**
|
|
1510
1569
|
* Maximum number of additional self-update re-exec attempts allowed after the
|
|
1511
1570
|
* first (i.e. up to MAX_SELF_UPDATE_ATTEMPTS + 1 total `npm install -g`
|
|
@@ -1690,12 +1749,26 @@ async function update() {
|
|
|
1690
1749
|
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
1691
1750
|
console.log("");
|
|
1692
1751
|
|
|
1693
|
-
console.log(` Mode: global (~/.claude)`);
|
|
1694
1752
|
console.log(` ${dim(getLogoTagline("update"))}`);
|
|
1695
1753
|
|
|
1696
1754
|
// Check if installed via npm (no .git directory) or via git clone
|
|
1697
1755
|
const gitDir = join(FORGE_HOME, ".git");
|
|
1698
1756
|
if (existsSync(gitDir)) {
|
|
1757
|
+
// forge#2220 — state the actual install being updated. Printing this
|
|
1758
|
+
// only after the git-dir check (rather than an unconditional "global"
|
|
1759
|
+
// line before it) is the fix: a clone resolved via npx-shadowing was
|
|
1760
|
+
// previously reported as "Mode: global (~/.claude)", which reads as
|
|
1761
|
+
// "your global install was updated" when in fact only this checkout was.
|
|
1762
|
+
console.log(` Mode: git clone at ${CYAN}${FORGE_HOME}${RESET}`);
|
|
1763
|
+
const separateGlobalInstall = findSeparateGlobalInstall();
|
|
1764
|
+
if (separateGlobalInstall) {
|
|
1765
|
+
console.log(
|
|
1766
|
+
` ${YELLOW}Note: this updates the git clone above. Your global npm install at ${separateGlobalInstall} was NOT touched.${RESET}`,
|
|
1767
|
+
);
|
|
1768
|
+
console.log(
|
|
1769
|
+
` ${YELLOW}Run 'npm update -g forgedock', or 'npx forgedock update' from outside a clone, to update it.${RESET}`,
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1699
1772
|
try {
|
|
1700
1773
|
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1701
1774
|
cwd: FORGE_HOME,
|
|
@@ -1708,6 +1781,61 @@ async function update() {
|
|
|
1708
1781
|
const isDetached = branch === "HEAD";
|
|
1709
1782
|
const needsCheckout = branch !== "main";
|
|
1710
1783
|
if (needsCheckout) {
|
|
1784
|
+
// Dirty-tree guard: refuse to move HEAD in this clone if there are
|
|
1785
|
+
// uncommitted TRACKED changes. Untracked files are intentionally
|
|
1786
|
+
// excluded (--untracked-files=no) so a clean checkout with stray
|
|
1787
|
+
// scratch/log files does not spuriously block the update.
|
|
1788
|
+
const porcelain = execFileSync(
|
|
1789
|
+
"git",
|
|
1790
|
+
["status", "--porcelain", "--untracked-files=no"],
|
|
1791
|
+
{ cwd: FORGE_HOME, encoding: "utf-8" },
|
|
1792
|
+
).trim();
|
|
1793
|
+
if (porcelain) {
|
|
1794
|
+
console.log(
|
|
1795
|
+
` ${YELLOW}Working tree has uncommitted changes on branch ${branch} — skipping git-based update.${RESET}`,
|
|
1796
|
+
);
|
|
1797
|
+
|
|
1798
|
+
// forge#2460 — distinguish "developing on this checkout" from a
|
|
1799
|
+
// dirty git-clone install elsewhere with unrelated changes. The
|
|
1800
|
+
// former (npx-shadowing: FORGE_HOME resolves to the directory the
|
|
1801
|
+
// user is physically running the CLI from) has a git-independent
|
|
1802
|
+
// fallback available — relinkAndHint() only copies commands/hooks
|
|
1803
|
+
// off disk, it never touches git state, so it's safe to run on a
|
|
1804
|
+
// dirty tree. HEAD is still never moved in either case.
|
|
1805
|
+
const cwdResolved = resolve(process.cwd());
|
|
1806
|
+
const forgeHomeResolved = resolve(FORGE_HOME);
|
|
1807
|
+
const isSourceRepoSelf =
|
|
1808
|
+
cwdResolved === forgeHomeResolved ||
|
|
1809
|
+
cwdResolved.startsWith(forgeHomeResolved + sep);
|
|
1810
|
+
|
|
1811
|
+
if (isSourceRepoSelf) {
|
|
1812
|
+
console.log(
|
|
1813
|
+
` ${YELLOW}This looks like the ForgeDock source repo itself — syncing commands/hooks from the working tree instead (HEAD was NOT moved).${RESET}`,
|
|
1814
|
+
);
|
|
1815
|
+
// forge#2493 — relinkAndHint() is wrapped in its own try/catch
|
|
1816
|
+
// here so a failure inside it (forge()/writeInstallReceipt()
|
|
1817
|
+
// disk-write errors) doesn't fall through to the outer catch
|
|
1818
|
+
// below, which prints a fast-forward/merge message that would be
|
|
1819
|
+
// actively wrong for this path — no fast-forward or merge is
|
|
1820
|
+
// ever attempted when isSourceRepoSelf is true.
|
|
1821
|
+
try {
|
|
1822
|
+
await relinkAndHint();
|
|
1823
|
+
} catch (relinkErr) {
|
|
1824
|
+
console.log(
|
|
1825
|
+
` ${YELLOW}Could not sync commands/hooks from the working tree: ${relinkErr.message}${RESET}`,
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
console.log(
|
|
1829
|
+
` To pull the latest published release instead: ${CYAN}npm install -g forgedock@latest && npx forgedock doctor --fix${RESET}`,
|
|
1830
|
+
);
|
|
1831
|
+
} else {
|
|
1832
|
+
console.log(
|
|
1833
|
+
` ${YELLOW}Commit or stash your changes before updating, or run from a clean clone. HEAD was NOT moved.${RESET}`,
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1711
1839
|
if (isDetached) {
|
|
1712
1840
|
console.log(
|
|
1713
1841
|
` ${YELLOW}Detached HEAD state — switching to main for update.${RESET}`,
|
|
@@ -1717,7 +1845,7 @@ async function update() {
|
|
|
1717
1845
|
` On branch ${CYAN}${branch}${RESET} — switching to ${CYAN}main${RESET} for update...`,
|
|
1718
1846
|
);
|
|
1719
1847
|
}
|
|
1720
|
-
|
|
1848
|
+
execFileSync("git", ["checkout", "main", "--quiet"], { cwd: FORGE_HOME });
|
|
1721
1849
|
}
|
|
1722
1850
|
|
|
1723
1851
|
const before = execSync("git rev-parse HEAD", {
|
|
@@ -1744,7 +1872,7 @@ async function update() {
|
|
|
1744
1872
|
// Restore original branch after a successful update.
|
|
1745
1873
|
if (needsCheckout && !isDetached) {
|
|
1746
1874
|
try {
|
|
1747
|
-
|
|
1875
|
+
execFileSync("git", ["checkout", branch, "--quiet"], { cwd: FORGE_HOME });
|
|
1748
1876
|
console.log(
|
|
1749
1877
|
` Restored branch ${CYAN}${branch}${RESET}.`,
|
|
1750
1878
|
);
|
|
@@ -1764,6 +1892,18 @@ async function update() {
|
|
|
1764
1892
|
// cache path (`npx forgedock`), which was never globally installed, so
|
|
1765
1893
|
// "npm update -g forgedock" is a no-op. Check the actual published
|
|
1766
1894
|
// version instead and give advice that reflects the real install model.
|
|
1895
|
+
//
|
|
1896
|
+
// forge#2220 — state the actual resolved install kind + path up front,
|
|
1897
|
+
// computed once and reused below, so the mode line can never drift from
|
|
1898
|
+
// the branch actually taken (isGlobalNpmInstall() is also consulted
|
|
1899
|
+
// later to decide whether to self-update in place).
|
|
1900
|
+
const globalInstall = isGlobalNpmInstall();
|
|
1901
|
+
console.log(
|
|
1902
|
+
globalInstall
|
|
1903
|
+
? ` Mode: global npm install at ${CYAN}${FORGE_HOME}${RESET}`
|
|
1904
|
+
: ` Mode: npx (ad-hoc, not installed) — resolved from ${CYAN}${FORGE_HOME}${RESET}`,
|
|
1905
|
+
);
|
|
1906
|
+
|
|
1767
1907
|
const localVersion = getVersion();
|
|
1768
1908
|
const latestVersion = await fetchLatestVersion();
|
|
1769
1909
|
|
|
@@ -1788,7 +1928,7 @@ async function update() {
|
|
|
1788
1928
|
// stale global binary). selfUpdateGlobalInstall() re-execs and exits
|
|
1789
1929
|
// the process on success, so anything after this block only runs when
|
|
1790
1930
|
// self-update wasn't attempted or failed.
|
|
1791
|
-
if (
|
|
1931
|
+
if (globalInstall) {
|
|
1792
1932
|
selfUpdateGlobalInstall(latestVersion);
|
|
1793
1933
|
// selfUpdateGlobalInstall() only returns (rather than exiting the
|
|
1794
1934
|
// process) when the install or re-exec failed — it already printed
|
|
@@ -3164,22 +3304,37 @@ async function demo() {
|
|
|
3164
3304
|
/**
|
|
3165
3305
|
* `forgedock watch` — live per-agent orchestration view
|
|
3166
3306
|
*
|
|
3167
|
-
* Polls GitHub every 5 seconds for in-flight issues (those
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3307
|
+
* Polls GitHub every 5 seconds for in-flight issues (those carrying any
|
|
3308
|
+
* label in the workflow-prefixed / needs-human family, derived from
|
|
3309
|
+
* bin/labels.json — see deriveWorkflowLabels() in bin/watch-utils.mjs,
|
|
3310
|
+
* forge#2235) and renders
|
|
3311
|
+
* a per-agent table with issue#, title, phase, elapsed time, and staleness
|
|
3312
|
+
* status. All data is sourced from FORGE:HEARTBEAT comments written by the
|
|
3313
|
+
* pipeline at each phase boundary.
|
|
3314
|
+
*
|
|
3315
|
+
* A second, independent "Findings" lane renders review-finding issues
|
|
3316
|
+
* regardless of workflow:* state (forge#2235) — a finding is born without
|
|
3317
|
+
* any workflow:* label and may never acquire one (deferred findings), so it
|
|
3318
|
+
* must not depend on the in-flight label set to be visible. Findings render
|
|
3319
|
+
* with a distinct queued/deferred/validated status, never "running".
|
|
3171
3320
|
*
|
|
3172
3321
|
* Stalled agents (no HEARTBEAT update within pipeline.stall_timeout_minutes)
|
|
3173
3322
|
* are highlighted in yellow.
|
|
3174
3323
|
*
|
|
3324
|
+
* If `forge.yaml` defines `repos.satellites`, each satellite repo is watched
|
|
3325
|
+
* in its own section in addition to the default/`--repo` repo.
|
|
3326
|
+
*
|
|
3175
3327
|
* Run: npx forgedock watch [--repo owner/repo]
|
|
3176
3328
|
* Exit: Ctrl+C
|
|
3177
3329
|
*/
|
|
3178
3330
|
async function watch() {
|
|
3179
3331
|
const POLL_INTERVAL_MS = 5000;
|
|
3180
3332
|
const USE_ANSI_WATCH = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
3333
|
+
const ISSUE_LIST_LIMIT = 500; // forge#2235 — was 30; matches phase-1-resolve.md's --limit 500 mandate
|
|
3334
|
+
const HEARTBEAT_BATCH_CHUNK_SIZE = 25; // keep GraphQL query size/complexity bounded
|
|
3181
3335
|
|
|
3182
|
-
// ── Resolve repo
|
|
3336
|
+
// ── Resolve repo(s) ────────────────────────────────────────────────────────
|
|
3337
|
+
const explicitRepoFlag = restArgs.includes("--repo");
|
|
3183
3338
|
const watchRepo = resolveLabelsRepo(restArgs);
|
|
3184
3339
|
if (!watchRepo) {
|
|
3185
3340
|
process.stderr.write(
|
|
@@ -3190,6 +3345,22 @@ async function watch() {
|
|
|
3190
3345
|
return;
|
|
3191
3346
|
}
|
|
3192
3347
|
|
|
3348
|
+
// Satellite repos (forge.yaml → repos.satellites) are watched in addition
|
|
3349
|
+
// to the primary repo, unless the caller explicitly overrode --repo (an
|
|
3350
|
+
// explicit --repo means "just this one repo"). Returns [] when no
|
|
3351
|
+
// satellites section is present, so single-repo behavior is unchanged.
|
|
3352
|
+
let satelliteRepos = [];
|
|
3353
|
+
if (!explicitRepoFlag) {
|
|
3354
|
+
const forgeYamlPathForSatellites = join(process.cwd(), "forge.yaml");
|
|
3355
|
+
if (existsSync(forgeYamlPathForSatellites)) {
|
|
3356
|
+
try {
|
|
3357
|
+
const raw = readFileSync(forgeYamlPathForSatellites, "utf-8");
|
|
3358
|
+
satelliteRepos = parseSatelliteRepos(raw).filter((r) => r !== watchRepo);
|
|
3359
|
+
} catch { /* ignore — stay default-repo-only */ }
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
const watchedRepos = [watchRepo, ...satelliteRepos];
|
|
3363
|
+
|
|
3193
3364
|
// ── Validate gh CLI auth ───────────────────────────────────────────────────
|
|
3194
3365
|
try {
|
|
3195
3366
|
execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -3253,26 +3424,88 @@ async function watch() {
|
|
|
3253
3424
|
}
|
|
3254
3425
|
|
|
3255
3426
|
// ── Render loop ────────────────────────────────────────────────────────────
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3427
|
+
// Derived from bin/labels.json (forge#2235) instead of a hardcoded literal,
|
|
3428
|
+
// so a newly-added workflow:* label (e.g. workflow:engine-error) is watched
|
|
3429
|
+
// automatically without a code change. Falls back to a conservative
|
|
3430
|
+
// built-in list if the manifest is missing/unreadable.
|
|
3431
|
+
const WORKFLOW_LABELS = deriveWorkflowLabels(join(__dirname, "labels.json"));
|
|
3432
|
+
|
|
3433
|
+
// Batch-fetch the latest FORGE:HEARTBEAT comment for a set of issue numbers
|
|
3434
|
+
// in ONE `gh api graphql` round-trip (chunked) instead of one REST call per
|
|
3435
|
+
// issue (forge#2235 — de-N+1). Falls back to the old per-issue REST fetch
|
|
3436
|
+
// for the whole set on any GraphQL error, so this optimization can never
|
|
3437
|
+
// make watch() go blind.
|
|
3438
|
+
async function fetchHeartbeatsBatch(repo, issueNumbers) {
|
|
3439
|
+
const result = new Map();
|
|
3440
|
+
if (issueNumbers.length === 0) return result;
|
|
3441
|
+
const [owner, name] = repo.split("/");
|
|
3442
|
+
if (!owner || !name) return fetchHeartbeatsPerIssueFallback(repo, issueNumbers);
|
|
3263
3443
|
|
|
3264
|
-
|
|
3265
|
-
|
|
3444
|
+
try {
|
|
3445
|
+
for (const chunk of chunkArray(issueNumbers, HEARTBEAT_BATCH_CHUNK_SIZE)) {
|
|
3446
|
+
const query = buildHeartbeatBatchQuery(owner, name, chunk);
|
|
3447
|
+
const raw = execFileSync(
|
|
3448
|
+
"gh",
|
|
3449
|
+
["api", "graphql", "-f", `query=${query}`],
|
|
3450
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3451
|
+
);
|
|
3452
|
+
const parsed = parseHeartbeatBatchResponse(JSON.parse(raw));
|
|
3453
|
+
for (const [num, body] of parsed) result.set(num, body);
|
|
3454
|
+
}
|
|
3455
|
+
return result;
|
|
3456
|
+
} catch {
|
|
3457
|
+
// GraphQL batch failed (auth scope gap, malformed query, rate limit) —
|
|
3458
|
+
// fall back to the pre-forge#2235 per-issue REST behavior so watch()
|
|
3459
|
+
// degrades gracefully instead of losing heartbeat data entirely.
|
|
3460
|
+
return fetchHeartbeatsPerIssueFallback(repo, issueNumbers);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function fetchHeartbeatsPerIssueFallback(repo, issueNumbers) {
|
|
3465
|
+
const result = new Map();
|
|
3466
|
+
for (const num of issueNumbers) {
|
|
3467
|
+
try {
|
|
3468
|
+
const commentsJson = execFileSync(
|
|
3469
|
+
"gh",
|
|
3470
|
+
["api", `repos/${repo}/issues/${num}/comments`,
|
|
3471
|
+
"--jq", "[.[] | select(.body | contains(\"FORGE:HEARTBEAT\"))] | last | .body // \"\""],
|
|
3472
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3473
|
+
).trim();
|
|
3474
|
+
if (commentsJson && commentsJson !== '""' && commentsJson !== "") {
|
|
3475
|
+
result.set(num, commentsJson.replace(/^"|"$/g, "").replace(/\\n/g, "\n"));
|
|
3476
|
+
} else {
|
|
3477
|
+
result.set(num, null);
|
|
3478
|
+
}
|
|
3479
|
+
} catch {
|
|
3480
|
+
result.set(num, null);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
return result;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
// Renders a normalized priority for the "Pri" column, accepting both the
|
|
3487
|
+
// canonical `priority:P<n>` label form and the bare `P<n>` form used by
|
|
3488
|
+
// some consumer repos (forge#2232 / forge#2235).
|
|
3489
|
+
function priorityCell(issue) {
|
|
3490
|
+
return normalizePriority(issue.labels || []) || "—";
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
async function renderRepo(repo) {
|
|
3494
|
+
const rows = [["#", "Pri", "Title", "Phase", "Elapsed", "Status"]];
|
|
3495
|
+
const findingsRows = [["#", "Pri", "Title", "Status"]];
|
|
3266
3496
|
let anyIssues = false;
|
|
3497
|
+
const inFlightNumbers = new Set();
|
|
3498
|
+
const issuesByLabel = [];
|
|
3267
3499
|
|
|
3500
|
+
// ── In-flight lane: one query per workflow:*/needs-human label ──────────
|
|
3268
3501
|
for (const label of WORKFLOW_LABELS) {
|
|
3269
3502
|
let issueJson;
|
|
3270
3503
|
try {
|
|
3271
|
-
// Use execFileSync with argument array to avoid shell injection via
|
|
3504
|
+
// Use execFileSync with argument array to avoid shell injection via repo
|
|
3272
3505
|
issueJson = execFileSync(
|
|
3273
3506
|
"gh",
|
|
3274
|
-
["issue", "list", "-R",
|
|
3275
|
-
"--label", label, "--limit",
|
|
3507
|
+
["issue", "list", "-R", repo, "--state", "open",
|
|
3508
|
+
"--label", label, "--limit", String(ISSUE_LIST_LIMIT), "--json", "number,title,labels"],
|
|
3276
3509
|
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3277
3510
|
);
|
|
3278
3511
|
} catch {
|
|
@@ -3282,26 +3515,32 @@ async function watch() {
|
|
|
3282
3515
|
let issues;
|
|
3283
3516
|
try { issues = JSON.parse(issueJson); } catch { continue; }
|
|
3284
3517
|
|
|
3518
|
+
if (issues.length === ISSUE_LIST_LIMIT) {
|
|
3519
|
+
process.stderr.write(
|
|
3520
|
+
`${YELLOW}WARNING: label '${label}' on ${repo} returned exactly ${ISSUE_LIST_LIMIT} issues — ` +
|
|
3521
|
+
`results may be truncated.${RESET}\n`,
|
|
3522
|
+
);
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
issuesByLabel.push({ label, issues });
|
|
3526
|
+
for (const issue of issues) inFlightNumbers.add(issue.number);
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
// Batch-fetch heartbeats once for every in-flight issue across all labels
|
|
3530
|
+
const heartbeats = await fetchHeartbeatsBatch(repo, [...inFlightNumbers]);
|
|
3531
|
+
|
|
3532
|
+
for (const { label, issues } of issuesByLabel) {
|
|
3285
3533
|
for (const issue of issues) {
|
|
3286
3534
|
anyIssues = true;
|
|
3287
3535
|
let phase = label.replace("workflow:", "");
|
|
3288
3536
|
let elapsed = null;
|
|
3289
3537
|
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3297
|
-
).trim();
|
|
3298
|
-
if (commentsJson && commentsJson !== '""' && commentsJson !== "") {
|
|
3299
|
-
const body = commentsJson.replace(/^"|"$/g, "").replace(/\\n/g, "\n");
|
|
3300
|
-
phase = extractPhase(body) || phase;
|
|
3301
|
-
const ts = extractTimestamp(body);
|
|
3302
|
-
elapsed = elapsedMin(ts);
|
|
3303
|
-
}
|
|
3304
|
-
} catch { /* use label-derived phase */ }
|
|
3538
|
+
const body = heartbeats.get(issue.number);
|
|
3539
|
+
if (body) {
|
|
3540
|
+
phase = extractPhase(body) || phase;
|
|
3541
|
+
const ts = extractTimestamp(body);
|
|
3542
|
+
elapsed = elapsedMin(ts);
|
|
3543
|
+
}
|
|
3305
3544
|
|
|
3306
3545
|
const isStalled = elapsed !== null && elapsed >= stallMinutes;
|
|
3307
3546
|
const isBlocked = label === "needs-human";
|
|
@@ -3320,6 +3559,7 @@ async function watch() {
|
|
|
3320
3559
|
|
|
3321
3560
|
rows.push([
|
|
3322
3561
|
`#${issue.number}`,
|
|
3562
|
+
priorityCell(issue),
|
|
3323
3563
|
titleColored,
|
|
3324
3564
|
phaseShort,
|
|
3325
3565
|
elapsedStr(elapsed),
|
|
@@ -3328,7 +3568,60 @@ async function watch() {
|
|
|
3328
3568
|
}
|
|
3329
3569
|
}
|
|
3330
3570
|
|
|
3571
|
+
// ── Findings lane: review-finding issues, regardless of workflow:* state ─
|
|
3572
|
+
// A finding is born without any workflow:* label and may never acquire
|
|
3573
|
+
// one (deferred/PERMANENT_DEFERRED findings) — this lane makes them
|
|
3574
|
+
// visible from creation instead of only during the narrow in-flight
|
|
3575
|
+
// window (forge#2235).
|
|
3576
|
+
let anyFindings = false;
|
|
3577
|
+
try {
|
|
3578
|
+
const findingsJson = execFileSync(
|
|
3579
|
+
"gh",
|
|
3580
|
+
["issue", "list", "-R", repo, "--state", "open",
|
|
3581
|
+
"--label", FINDINGS_LANE_LABEL, "--limit", String(ISSUE_LIST_LIMIT), "--json", "number,title,labels"],
|
|
3582
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3583
|
+
);
|
|
3584
|
+
const findings = JSON.parse(findingsJson);
|
|
3585
|
+
if (findings.length === ISSUE_LIST_LIMIT) {
|
|
3586
|
+
process.stderr.write(
|
|
3587
|
+
`${YELLOW}WARNING: label '${FINDINGS_LANE_LABEL}' on ${repo} returned exactly ${ISSUE_LIST_LIMIT} issues — ` +
|
|
3588
|
+
`results may be truncated.${RESET}\n`,
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
for (const issue of findings) {
|
|
3592
|
+
// Findings that already carry a workflow:* label are in-flight and
|
|
3593
|
+
// already rendered above — do not double-render them here.
|
|
3594
|
+
if (hasWorkflowLabel(issue.labels || [], WORKFLOW_LABELS)) continue;
|
|
3595
|
+
|
|
3596
|
+
anyFindings = true;
|
|
3597
|
+
const findingStatus = classifyFindingStatus(issue.labels || []); // validated | false-positive | queued | deferred
|
|
3598
|
+
const statusDisplay = findingStatus.toUpperCase();
|
|
3599
|
+
const statusColored = USE_ANSI_WATCH
|
|
3600
|
+
? (findingStatus === "validated" ? `\x1b[32m${statusDisplay}\x1b[0m`
|
|
3601
|
+
: findingStatus === "false-positive" ? `\x1b[90m${statusDisplay}\x1b[0m`
|
|
3602
|
+
: `\x1b[36m${statusDisplay}\x1b[0m`) // queued / deferred
|
|
3603
|
+
: statusDisplay;
|
|
3604
|
+
|
|
3605
|
+
const titleTrunc = issue.title.length > 38 ? issue.title.slice(0, 35) + "..." : issue.title;
|
|
3606
|
+
|
|
3607
|
+
findingsRows.push([
|
|
3608
|
+
`#${issue.number}`,
|
|
3609
|
+
priorityCell(issue),
|
|
3610
|
+
titleTrunc,
|
|
3611
|
+
statusColored,
|
|
3612
|
+
]);
|
|
3613
|
+
}
|
|
3614
|
+
} catch { /* findings lane best-effort — never blocks the in-flight table */ }
|
|
3615
|
+
|
|
3616
|
+
return { repo, rows, anyIssues, findingsRows, anyFindings };
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
async function render() {
|
|
3331
3620
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
3621
|
+
const results = [];
|
|
3622
|
+
for (const repo of watchedRepos) {
|
|
3623
|
+
results.push(await renderRepo(repo));
|
|
3624
|
+
}
|
|
3332
3625
|
|
|
3333
3626
|
if (USE_ANSI_WATCH) {
|
|
3334
3627
|
process.stdout.write("\x1b[2J\x1b[H"); // clear screen + home
|
|
@@ -3336,12 +3629,26 @@ async function watch() {
|
|
|
3336
3629
|
process.stdout.write(`\n${"─".repeat(60)}\n`);
|
|
3337
3630
|
}
|
|
3338
3631
|
|
|
3339
|
-
|
|
3632
|
+
const repoLabel = watchedRepos.length > 1 ? `${watchedRepos.length} repos` : watchRepo;
|
|
3633
|
+
process.stdout.write(`${BOLD}ForgeDock Watch${RESET} — ${dim(repoLabel)} ${dim(now)}\n\n`);
|
|
3340
3634
|
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3635
|
+
for (const { repo, rows, anyIssues, findingsRows, anyFindings } of results) {
|
|
3636
|
+
if (watchedRepos.length > 1) {
|
|
3637
|
+
process.stdout.write(`${BOLD}${repo}${RESET}\n`);
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
if (!anyIssues || rows.length === 1) {
|
|
3641
|
+
process.stdout.write(` ${dim("No in-flight issues. All quiet.")}\n`);
|
|
3642
|
+
} else {
|
|
3643
|
+
process.stdout.write(table(rows, { header: true }));
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
if (anyFindings && findingsRows.length > 1) {
|
|
3647
|
+
process.stdout.write(`\n${BOLD}Findings${RESET} ${dim("(review-finding — queued/deferred/validated)")}\n`);
|
|
3648
|
+
process.stdout.write(table(findingsRows, { header: true }));
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
if (watchedRepos.length > 1) process.stdout.write("\n");
|
|
3345
3652
|
}
|
|
3346
3653
|
|
|
3347
3654
|
if (USE_ANSI_WATCH) {
|
|
@@ -3469,7 +3776,13 @@ switch (command) {
|
|
|
3469
3776
|
// forge#2175: make a needs-human escalation distinguishable from a
|
|
3470
3777
|
// successful run by exit code, mirroring the resume-stalled case's
|
|
3471
3778
|
// existing convention below (result.failed.length > 0 -> exitCode 1).
|
|
3472
|
-
|
|
3779
|
+
// forge#2261: "engine-error" (the engine/tool itself broke — a fail-fast
|
|
3780
|
+
// CLI_BACKEND_FAILED/NO_API_KEY/NO_SDK, or an exhausted retry loop where
|
|
3781
|
+
// the runner never once succeeded) previously reached this same case via
|
|
3782
|
+
// an uncaught throw, which already set exitCode = 1 below. Now that
|
|
3783
|
+
// runIssue() resolves cleanly with a terminalReason instead of throwing,
|
|
3784
|
+
// preserve that same non-zero exit code for this reason too.
|
|
3785
|
+
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error")) exitCode = 1;
|
|
3473
3786
|
} catch (err) {
|
|
3474
3787
|
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
3475
3788
|
exitCode = 1;
|
|
@@ -3568,6 +3881,31 @@ switch (command) {
|
|
|
3568
3881
|
const localVersion = getVersion();
|
|
3569
3882
|
console.log(`forgedock ${localVersion ? `v${localVersion}` : `${YELLOW}version unknown${RESET}`}`);
|
|
3570
3883
|
|
|
3884
|
+
// forge#2460 — getVersion() always reads FORGE_HOME/package.json. In a
|
|
3885
|
+
// git-clone install (including the npx-shadowing case where FORGE_HOME
|
|
3886
|
+
// resolves to whatever checkout the CLI is being run from), that's this
|
|
3887
|
+
// checkout's own version — not necessarily what a separate global npm
|
|
3888
|
+
// install elsewhere would report. Surface that ambiguity explicitly
|
|
3889
|
+
// rather than letting this read as "the" installed version, mirroring
|
|
3890
|
+
// the disambiguation messaging update() already prints (forge#2220).
|
|
3891
|
+
// WIRE:PROVEN — manual: every existing "version command" test in
|
|
3892
|
+
// bin/tests/router.test.mjs spawns this actual bin/forgedock.mjs file,
|
|
3893
|
+
// whose FORGE_HOME resolves to this repo's own git-clone root (a real
|
|
3894
|
+
// .git directory), so this guard evaluates true on every run of that
|
|
3895
|
+
// suite (`node --test bin/tests/router.test.mjs`, confirmed passing).
|
|
3896
|
+
// findSeparateGlobalInstall() itself fails closed to null on any error
|
|
3897
|
+
// (see its JSDoc) and is exercised by the "update — global npm install
|
|
3898
|
+
// self-update" suite below via the same resolveGlobalNpmForgedockDir()
|
|
3899
|
+
// helper it shares with isGlobalNpmInstall().
|
|
3900
|
+
if (existsSync(join(FORGE_HOME, ".git"))) {
|
|
3901
|
+
const separateGlobalInstall = findSeparateGlobalInstall();
|
|
3902
|
+
if (separateGlobalInstall) {
|
|
3903
|
+
console.log(
|
|
3904
|
+
` ${YELLOW}Note: this is the git clone at ${FORGE_HOME}. Your global npm install at ${separateGlobalInstall} may report a different version.${RESET}`,
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3571
3909
|
const latestVersion = await fetchLatestVersion();
|
|
3572
3910
|
if (latestVersion && localVersion && compareVersions(latestVersion, localVersion) > 0) {
|
|
3573
3911
|
console.log(
|