forgedock 1.1.9 → 1.1.11
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/forgedock.mjs +79 -4
- package/bin/init-detect.mjs +1 -1
- package/bin/journey.mjs +333 -49
- package/commands/validate.md +12 -4
- package/commands/work-on/remediate.md +10 -2
- package/commands/work-on.md +10 -1
- package/package.json +1 -1
- package/scripts/danger-zones.mjs +3 -1
- package/scripts/transition-label.sh +80 -28
package/bin/forgedock.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
parseInstallTier,
|
|
29
29
|
writeForgeYaml,
|
|
30
30
|
backupExisting,
|
|
31
|
+
backfillForgeYaml,
|
|
31
32
|
manualLowConfidenceKeys,
|
|
32
33
|
isEphemeralCachePath,
|
|
33
34
|
writeInstallReceipt,
|
|
@@ -1874,6 +1875,16 @@ async function doctor(fix = false) {
|
|
|
1874
1875
|
* Reuses forge() (bin/journey.mjs) verbatim — the same repair path already
|
|
1875
1876
|
* used by `npx forgedock update` (see relinkAndHint()) — rather than
|
|
1876
1877
|
* duplicating symlink/copy-mode or hook-registration logic here.
|
|
1878
|
+
*
|
|
1879
|
+
* NOTE: `installRepairRan` only memoizes whether the repair primitive
|
|
1880
|
+
* itself has executed during this invocation — it is a cross-check flag
|
|
1881
|
+
* by necessity (any of the three checks below can trigger it, and it must
|
|
1882
|
+
* not re-run for the other two). It must NOT be read by an individual
|
|
1883
|
+
* check to decide whether THAT check was fixed — a check that was already
|
|
1884
|
+
* healthy before a sibling check triggered repair would incorrectly
|
|
1885
|
+
* report "repaired" too. Each check below tracks its own pre-repair
|
|
1886
|
+
* broken state in a local `*WasBroken` variable for that decision instead.
|
|
1887
|
+
* (forge#1975)
|
|
1877
1888
|
*/
|
|
1878
1889
|
let installRepairRan = false;
|
|
1879
1890
|
async function runInstallRepairOnce() {
|
|
@@ -1954,7 +1965,9 @@ async function doctor(fix = false) {
|
|
|
1954
1965
|
}
|
|
1955
1966
|
|
|
1956
1967
|
let cmdFiles = await detectCommandFiles();
|
|
1968
|
+
let cmdFilesWasBroken = false;
|
|
1957
1969
|
if (!cmdFiles.error && !cmdFiles.ok && fix) {
|
|
1970
|
+
cmdFilesWasBroken = true;
|
|
1958
1971
|
await runInstallRepairOnce();
|
|
1959
1972
|
cmdFiles = await detectCommandFiles();
|
|
1960
1973
|
}
|
|
@@ -1962,7 +1975,7 @@ async function doctor(fix = false) {
|
|
|
1962
1975
|
if (cmdFiles.error) {
|
|
1963
1976
|
fail("Command files", `Could not read commands directory: ${cmdFiles.error}`);
|
|
1964
1977
|
} else if (cmdFiles.ok) {
|
|
1965
|
-
if (fix &&
|
|
1978
|
+
if (fix && cmdFilesWasBroken) {
|
|
1966
1979
|
fixed("Command files", `${cmdFiles.checked} files installed (repaired)`);
|
|
1967
1980
|
} else {
|
|
1968
1981
|
pass("Command files", `${cmdFiles.checked} files installed`);
|
|
@@ -2153,7 +2166,9 @@ async function doctor(fix = false) {
|
|
|
2153
2166
|
}
|
|
2154
2167
|
|
|
2155
2168
|
let hookReg = detectSessionStartHookRegistered();
|
|
2169
|
+
let hookRegWasBroken = false;
|
|
2156
2170
|
if (!hookReg.error && !hookReg.ok && fix) {
|
|
2171
|
+
hookRegWasBroken = true;
|
|
2157
2172
|
await runInstallRepairOnce();
|
|
2158
2173
|
hookReg = detectSessionStartHookRegistered();
|
|
2159
2174
|
}
|
|
@@ -2161,7 +2176,7 @@ async function doctor(fix = false) {
|
|
|
2161
2176
|
if (hookReg.error) {
|
|
2162
2177
|
fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${hookReg.error}. Run: npx forgedock install`);
|
|
2163
2178
|
} else if (hookReg.ok) {
|
|
2164
|
-
if (fix &&
|
|
2179
|
+
if (fix && hookRegWasBroken) {
|
|
2165
2180
|
fixed("SessionStart hook", "registered in ~/.claude/settings.json (repaired)");
|
|
2166
2181
|
} else {
|
|
2167
2182
|
pass("SessionStart hook", "registered in ~/.claude/settings.json");
|
|
@@ -2221,7 +2236,9 @@ async function doctor(fix = false) {
|
|
|
2221
2236
|
// ever surfaced. This check catches that silent-failure case. (forge#1895)
|
|
2222
2237
|
{
|
|
2223
2238
|
let hookPathResult = detectSessionStartHookPath();
|
|
2239
|
+
let hookPathWasBroken = false;
|
|
2224
2240
|
if (hookPathResult.state === "dangling" && fix) {
|
|
2241
|
+
hookPathWasBroken = true;
|
|
2225
2242
|
await runInstallRepairOnce();
|
|
2226
2243
|
hookPathResult = detectSessionStartHookPath();
|
|
2227
2244
|
}
|
|
@@ -2237,7 +2254,7 @@ async function doctor(fix = false) {
|
|
|
2237
2254
|
`Could not parse a script path out of the registered hook command ("${hookPathResult.command}"). Run: npx forgedock install`,
|
|
2238
2255
|
);
|
|
2239
2256
|
} else if (hookPathResult.state === "ok") {
|
|
2240
|
-
if (fix &&
|
|
2257
|
+
if (fix && hookPathWasBroken) {
|
|
2241
2258
|
fixed("SessionStart hook script path", `refreshed to a valid path (${hookPathResult.path})`);
|
|
2242
2259
|
} else {
|
|
2243
2260
|
pass("SessionStart hook script path", `exists on disk (${hookPathResult.path})`);
|
|
@@ -2709,12 +2726,14 @@ function help() {
|
|
|
2709
2726
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
2710
2727
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
2711
2728
|
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
2729
|
+
["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
|
|
2712
2730
|
["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
|
|
2713
2731
|
["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
|
|
2714
2732
|
["npx forgedock doctor", "Check installation health"],
|
|
2715
2733
|
["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
|
|
2716
2734
|
["npx forgedock update", "Pull latest & reinstall"],
|
|
2717
2735
|
["npx forgedock uninstall", "Remove commands"],
|
|
2736
|
+
["npx forgedock version", "Print the installed version and check for updates"],
|
|
2718
2737
|
["npx forgedock help", "Show this help"],
|
|
2719
2738
|
];
|
|
2720
2739
|
const flagRows = [
|
|
@@ -2723,6 +2742,7 @@ function help() {
|
|
|
2723
2742
|
["--manual", "Plain text prompts instead of the review screen (init)"],
|
|
2724
2743
|
["--verbose", "Show detection sources for every field (init)"],
|
|
2725
2744
|
["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
|
|
2745
|
+
["--version, -v", "Print the installed version and check for updates"],
|
|
2726
2746
|
];
|
|
2727
2747
|
|
|
2728
2748
|
process.stdout.write(
|
|
@@ -3051,7 +3071,8 @@ async function watch() {
|
|
|
3051
3071
|
const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3052
3072
|
const KNOWN_COMMANDS = new Set([
|
|
3053
3073
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3054
|
-
"run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "help", "--help", "-h",
|
|
3074
|
+
"run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
|
|
3075
|
+
"version", "--version", "-v",
|
|
3055
3076
|
]);
|
|
3056
3077
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
3057
3078
|
|
|
@@ -3200,6 +3221,60 @@ switch (command) {
|
|
|
3200
3221
|
}
|
|
3201
3222
|
break;
|
|
3202
3223
|
}
|
|
3224
|
+
case "config": {
|
|
3225
|
+
const subcommand = positional[1];
|
|
3226
|
+
if (subcommand === "migrate") {
|
|
3227
|
+
// Directory override mirrors `status [dir]` — defaults to cwd.
|
|
3228
|
+
const dir = positional[2] ? resolve(positional[2]) : process.cwd();
|
|
3229
|
+
const result = backfillForgeYaml(dir);
|
|
3230
|
+
if (!result.present) {
|
|
3231
|
+
console.log(
|
|
3232
|
+
`${RED}No forge.yaml found in ${dir}.${RESET}\n` +
|
|
3233
|
+
` Run ${cyan("npx forgedock init")} to generate one.`,
|
|
3234
|
+
);
|
|
3235
|
+
exitCode = 1;
|
|
3236
|
+
} else if (result.added.length === 0) {
|
|
3237
|
+
console.log(
|
|
3238
|
+
`${GREEN}forge.yaml already has all ${result.alreadyPresent.length} optional sections.${RESET} Nothing to migrate.`,
|
|
3239
|
+
);
|
|
3240
|
+
} else {
|
|
3241
|
+
console.log(`${GREEN}Backfilled ${result.added.length} missing optional section(s) into forge.yaml:${RESET}`);
|
|
3242
|
+
for (const key of result.added) console.log(` ${GREEN}+${RESET} ${key}`);
|
|
3243
|
+
console.log(`\n Sections already present (unchanged): ${result.alreadyPresent.join(", ") || "none"}`);
|
|
3244
|
+
console.log(`\n All added sections are commented out — edit forge.yaml to enable the ones you need.`);
|
|
3245
|
+
}
|
|
3246
|
+
} else {
|
|
3247
|
+
console.log(`${RED}Unknown config subcommand: ${subcommand ?? "(none)"}${RESET}`);
|
|
3248
|
+
console.log(
|
|
3249
|
+
`Usage: ${CYAN}npx forgedock config migrate [dir]${RESET}\n` +
|
|
3250
|
+
` Backfills any of the 16 optional forge.yaml sections missing from an existing config (idempotent).`,
|
|
3251
|
+
);
|
|
3252
|
+
exitCode = 1;
|
|
3253
|
+
}
|
|
3254
|
+
break;
|
|
3255
|
+
}
|
|
3256
|
+
case "version":
|
|
3257
|
+
case "--version":
|
|
3258
|
+
case "-v": {
|
|
3259
|
+
// Print the local version immediately — must work offline/instantly,
|
|
3260
|
+
// no network dependency for the primary output (getVersion() reads
|
|
3261
|
+
// package.json off disk only). The latest-version check below is
|
|
3262
|
+
// best-effort: fetchLatestVersion() has its own 5s timeout and never
|
|
3263
|
+
// rejects, so a slow/offline network never blocks or fails this
|
|
3264
|
+
// command. Mirrors the version-check message pattern already used by
|
|
3265
|
+
// update() (see the npm/npx branch above).
|
|
3266
|
+
const localVersion = getVersion();
|
|
3267
|
+
console.log(`forgedock ${localVersion ? `v${localVersion}` : `${YELLOW}version unknown${RESET}`}`);
|
|
3268
|
+
|
|
3269
|
+
const latestVersion = await fetchLatestVersion();
|
|
3270
|
+
if (latestVersion && localVersion && compareVersions(latestVersion, localVersion) > 0) {
|
|
3271
|
+
console.log(
|
|
3272
|
+
` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}). ` +
|
|
3273
|
+
`Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
break;
|
|
3277
|
+
}
|
|
3203
3278
|
case "help":
|
|
3204
3279
|
case "--help":
|
|
3205
3280
|
case "-h":
|
package/bin/init-detect.mjs
CHANGED
|
@@ -283,7 +283,7 @@ export function resolveGitRoot(cwd) {
|
|
|
283
283
|
if (toplevel) {
|
|
284
284
|
const resolved = resolve(toplevel) !== resolve(cwd);
|
|
285
285
|
return {
|
|
286
|
-
root: resolved ? toplevel : cwd,
|
|
286
|
+
root: resolved ? resolve(toplevel) : cwd,
|
|
287
287
|
resolved,
|
|
288
288
|
why: `'${cwd}' is nested inside a git repository rooted at '${toplevel}'`,
|
|
289
289
|
};
|
package/bin/journey.mjs
CHANGED
|
@@ -23,6 +23,242 @@ function todo(key, low) {
|
|
|
23
23
|
return low.includes(key) ? ` # TODO(forgedock:${key}) — verify this value` : "";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Ordered list of the 16 optional forge.yaml sections. Each entry is a
|
|
28
|
+
* self-contained, fully-commented stub block (banner + example content).
|
|
29
|
+
* This is the single source of truth for optional-section text, shared by
|
|
30
|
+
* writeForgeYaml() (full-file generation for `npx forgedock init`) and
|
|
31
|
+
* backfillForgeYaml() (append-only migration for existing files, #1982) —
|
|
32
|
+
* keeping the two paths from drifting apart the same way full-generation
|
|
33
|
+
* itself drifted from forge.yaml.example before #1983.
|
|
34
|
+
*
|
|
35
|
+
* `key` is the top-level YAML key the section introduces (used by
|
|
36
|
+
* backfillForgeYaml() to detect whether a section is already present).
|
|
37
|
+
* `block(v)` renders the stub text; blocks never end with a trailing
|
|
38
|
+
* newline of their own — callers join them with `"\n\n"` to reproduce the
|
|
39
|
+
* blank-line separation between sections.
|
|
40
|
+
* @type {Array<{ key: string, block: (v: object) => string }>}
|
|
41
|
+
*/
|
|
42
|
+
const OPTIONAL_SECTIONS = [
|
|
43
|
+
{
|
|
44
|
+
key: "agents",
|
|
45
|
+
block: () => `# =============================================================================
|
|
46
|
+
# AGENTS (OPTIONAL) — model overrides for pipeline agents.
|
|
47
|
+
# Commands: all commands with an "Agent model policy" line
|
|
48
|
+
# =============================================================================
|
|
49
|
+
|
|
50
|
+
# agents:
|
|
51
|
+
# default_model: "sonnet" # main orchestrator/agent model: "sonnet" | "opus" | "haiku"
|
|
52
|
+
# subagent_model: "sonnet" # model for child sub-agents (orchestrate, review-pr, ...)`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "repos",
|
|
56
|
+
block: (v) => `# =============================================================================
|
|
57
|
+
# REPOS (OPTIONAL) — multi-repo configuration. Remove the # to enable.
|
|
58
|
+
# =============================================================================
|
|
59
|
+
|
|
60
|
+
# repos:
|
|
61
|
+
# default:
|
|
62
|
+
# repo: "${esc(v.owner)}/${esc(v.repo)}"
|
|
63
|
+
# staging_branch: "${esc(v.stagingBranch)}"
|
|
64
|
+
# satellites:
|
|
65
|
+
# - prefix: "mcp"
|
|
66
|
+
# repo: "${esc(v.owner)}/your-satellite-repo"
|
|
67
|
+
# staging_branch: "main"`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: "project_board",
|
|
71
|
+
block: (v) => `# =============================================================================
|
|
72
|
+
# PROJECT BOARD (OPTIONAL) — GitHub Projects v2 integration.
|
|
73
|
+
# To find IDs: gh project list --owner ${esc(v.owner)}
|
|
74
|
+
# =============================================================================
|
|
75
|
+
|
|
76
|
+
# project_board:
|
|
77
|
+
# owner: "${esc(v.owner)}"
|
|
78
|
+
# project_number: 1
|
|
79
|
+
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
80
|
+
# field_ids:
|
|
81
|
+
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "pipeline",
|
|
85
|
+
block: () => `# =============================================================================
|
|
86
|
+
# PIPELINE (OPTIONAL) — tuning knobs for the /orchestrate batch engine.
|
|
87
|
+
# Commands: orchestrate
|
|
88
|
+
# =============================================================================
|
|
89
|
+
|
|
90
|
+
# pipeline:
|
|
91
|
+
# stall_timeout_minutes: 15 # minutes an agent may sit idle before auto-resume
|
|
92
|
+
# token_budget_per_batch: 900000 # per-batch token ceiling for the review-finding cascade
|
|
93
|
+
# token_estimate_per_finding: 150000
|
|
94
|
+
# narration: "terse" # "terse" | "verbose"`,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: "services",
|
|
98
|
+
block: () => `# =============================================================================
|
|
99
|
+
# SERVICES (OPTIONAL) — external service URLs for analytics/monitoring/GEO audit.
|
|
100
|
+
# Commands: analytics, geo-audit, autopilot (analytics snapshot)
|
|
101
|
+
# =============================================================================
|
|
102
|
+
|
|
103
|
+
# services:
|
|
104
|
+
# domain: "acme.io"
|
|
105
|
+
# gsc_property: "https://acme.io"
|
|
106
|
+
# app_url: "https://acme.io"
|
|
107
|
+
# api_url: "https://api.acme.io"
|
|
108
|
+
# analytics:
|
|
109
|
+
# umami:
|
|
110
|
+
# url: "https://umami.acme.io"
|
|
111
|
+
# website_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
112
|
+
# clarity:
|
|
113
|
+
# project_id: "xxxxxxxxxx"
|
|
114
|
+
# ga4:
|
|
115
|
+
# property_id: "000000000"`,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: "review",
|
|
119
|
+
block: () => `# =============================================================================
|
|
120
|
+
# REVIEW (OPTIONAL) — context injected into review agent prompts.
|
|
121
|
+
# =============================================================================
|
|
122
|
+
|
|
123
|
+
# review:
|
|
124
|
+
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
125
|
+
# context: |
|
|
126
|
+
# Describe your repo structure and any unusual conventions here.`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
key: "verification",
|
|
130
|
+
block: () => `# =============================================================================
|
|
131
|
+
# VERIFICATION (OPTIONAL) — health checks for quality gate / validate.
|
|
132
|
+
# =============================================================================
|
|
133
|
+
|
|
134
|
+
# verification:
|
|
135
|
+
# health_endpoint: "https://api.example.com/health"
|
|
136
|
+
# health_patterns:
|
|
137
|
+
# - '"status": "ok"'`,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: "deploy",
|
|
141
|
+
block: () => `# =============================================================================
|
|
142
|
+
# DEPLOY (OPTIONAL) — deployment model configuration.
|
|
143
|
+
# Commands: deploy-info, incident-response, rollback, work-on (Phase 3J)
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
# deploy:
|
|
147
|
+
# workflow: "deploy.yml" # GitHub Actions workflow filename used to trigger deploys
|
|
148
|
+
# workflow_inputs:
|
|
149
|
+
# services: "services"
|
|
150
|
+
# reason: "reason"
|
|
151
|
+
# secrets_backend: "sops" # sops | aws-sm | vault | ci-env | none`,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: "autopilot",
|
|
155
|
+
block: () => `# =============================================================================
|
|
156
|
+
# AUTOPILOT (OPTIONAL) — configuration for the /autopilot autonomous deploy loop.
|
|
157
|
+
# Commands: autopilot
|
|
158
|
+
# =============================================================================
|
|
159
|
+
|
|
160
|
+
# autopilot:
|
|
161
|
+
# ops_issue_label: "autopilot-ops"
|
|
162
|
+
# headless: false # opt-in to unattended fixing; default: false
|
|
163
|
+
# approve:
|
|
164
|
+
# p0: needs-human
|
|
165
|
+
# p1: needs-human
|
|
166
|
+
# p2: auto
|
|
167
|
+
# p3: auto
|
|
168
|
+
# budget:
|
|
169
|
+
# per_cycle_fixes: 3
|
|
170
|
+
# per_cycle_tokens: null`,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
key: "billing",
|
|
174
|
+
block: () => `# =============================================================================
|
|
175
|
+
# BILLING (OPTIONAL) — financial integrity checks in /security-audit.
|
|
176
|
+
# Commands: security-audit (Phase 4 — Financial Integrity)
|
|
177
|
+
# =============================================================================
|
|
178
|
+
|
|
179
|
+
# billing:
|
|
180
|
+
# enabled: false`,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: "devdocs",
|
|
184
|
+
block: () => `# =============================================================================
|
|
185
|
+
# DEVDOCS (OPTIONAL) — path to the devdocs knowledge tree.
|
|
186
|
+
# Commands: docs init
|
|
187
|
+
# =============================================================================
|
|
188
|
+
|
|
189
|
+
# devdocs:
|
|
190
|
+
# path: "devdocs"`,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: "adaptive_scripts",
|
|
194
|
+
block: () => `# =============================================================================
|
|
195
|
+
# ADAPTIVE_SCRIPTS (OPTIONAL) — per-repo scripts ForgeDock learns and updates.
|
|
196
|
+
# Commands: work-on (script discovery), optimize (script generation)
|
|
197
|
+
# =============================================================================
|
|
198
|
+
|
|
199
|
+
# adaptive_scripts:
|
|
200
|
+
# enabled: true
|
|
201
|
+
# directory: ".forgedock/scripts"
|
|
202
|
+
# commit: false`,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
key: "learned",
|
|
206
|
+
block: () => `# =============================================================================
|
|
207
|
+
# LEARNED (OPTIONAL) — agent-writable patterns captured across sessions.
|
|
208
|
+
# Commands: work-on (Phase 0B reads; Phase 1D writes)
|
|
209
|
+
# =============================================================================
|
|
210
|
+
|
|
211
|
+
# learned:
|
|
212
|
+
# branch_targets:
|
|
213
|
+
# staging: "develop"
|
|
214
|
+
# test_commands:
|
|
215
|
+
# - "pnpm typecheck"
|
|
216
|
+
# label_map:
|
|
217
|
+
# "workflow:investigating": "needs-triage"
|
|
218
|
+
# commit_style: "conventional-with-scope"`,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
key: "index",
|
|
222
|
+
block: () => `# =============================================================================
|
|
223
|
+
# INDEX (OPTIONAL) — per-commit code index for scripts/code-index.sh.
|
|
224
|
+
# Commands: work-on:investigate, work-on/build/architect, review-pr
|
|
225
|
+
# =============================================================================
|
|
226
|
+
|
|
227
|
+
# index:
|
|
228
|
+
# languages: "Python,JavaScript,TypeScript,Go"
|
|
229
|
+
# cache_dir: ".forge/index"
|
|
230
|
+
# enabled: true`,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
key: "attribution",
|
|
234
|
+
block: () => `# =============================================================================
|
|
235
|
+
# ATTRIBUTION (OPTIONAL) — opt-in growth features (PR/annotation footer links).
|
|
236
|
+
# Commands: work-on/review, review-pr
|
|
237
|
+
# =============================================================================
|
|
238
|
+
|
|
239
|
+
# attribution:
|
|
240
|
+
# pr_footer: true
|
|
241
|
+
# annotation_link: true`,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
key: "pattern_feeds",
|
|
245
|
+
block: () => `# =============================================================================
|
|
246
|
+
# PATTERN_FEEDS (OPTIONAL) — subscribe to external pattern card repositories.
|
|
247
|
+
# Commands: scripts/build-knowledge-index.mjs, quality-gate, optimize
|
|
248
|
+
# =============================================================================
|
|
249
|
+
|
|
250
|
+
# pattern_feeds:
|
|
251
|
+
# feeds:
|
|
252
|
+
# - slug: "forge-core"
|
|
253
|
+
# repo: "RapierCraftStudios/forge-patterns"
|
|
254
|
+
# ref: "abc1234def5678..." # REQUIRED: pinned commit SHA — never a branch name
|
|
255
|
+
# path: "cards"
|
|
256
|
+
# stacks: ["node", "bash", "gha"]
|
|
257
|
+
# priority: "LOW"
|
|
258
|
+
# enabled: true`,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
26
262
|
/**
|
|
27
263
|
* Write forge.yaml from reviewed values.
|
|
28
264
|
* @param {{owner:string,repo:string,name:string,description:string,root:string,
|
|
@@ -34,13 +270,15 @@ function todo(key, low) {
|
|
|
34
270
|
*/
|
|
35
271
|
export function writeForgeYaml(v, lowConfidenceKeys, outputPath) {
|
|
36
272
|
const low = lowConfidenceKeys;
|
|
37
|
-
const
|
|
273
|
+
const header = `# forge.yaml — ForgeDock Configuration
|
|
38
274
|
#
|
|
39
275
|
# Auto-generated by: npx forgedock
|
|
40
276
|
# Fields marked with TODO comments below were guessed — verify them.
|
|
41
277
|
#
|
|
42
278
|
# Required sections: project, paths, branches
|
|
43
|
-
# Optional sections: repos, project_board, services, review,
|
|
279
|
+
# Optional sections: agents, repos, project_board, pipeline, services, review,
|
|
280
|
+
# verification, deploy, autopilot, billing, devdocs, adaptive_scripts,
|
|
281
|
+
# learned, index, attribution, pattern_feeds
|
|
44
282
|
#
|
|
45
283
|
# See docs/CONFIG.md for full reference.
|
|
46
284
|
|
|
@@ -69,51 +307,9 @@ paths:
|
|
|
69
307
|
branches:
|
|
70
308
|
default: "${esc(v.defaultBranch)}"${todo("defaultBranch", low)}
|
|
71
309
|
staging: "${esc(v.stagingBranch)}"${todo("stagingBranch", low)}
|
|
72
|
-
feature_pattern: "milestone/{slug}"
|
|
73
|
-
|
|
74
|
-
# =============================================================================
|
|
75
|
-
# REPOS (OPTIONAL) — multi-repo configuration. Remove the # to enable.
|
|
76
|
-
# =============================================================================
|
|
77
|
-
|
|
78
|
-
# repos:
|
|
79
|
-
# default:
|
|
80
|
-
# repo: "${esc(v.owner)}/${esc(v.repo)}"
|
|
81
|
-
# staging_branch: "${esc(v.stagingBranch)}"
|
|
82
|
-
# satellites:
|
|
83
|
-
# - prefix: "mcp"
|
|
84
|
-
# repo: "${esc(v.owner)}/your-satellite-repo"
|
|
85
|
-
# staging_branch: "main"
|
|
86
|
-
|
|
87
|
-
# =============================================================================
|
|
88
|
-
# PROJECT BOARD (OPTIONAL) — GitHub Projects v2 integration.
|
|
89
|
-
# To find IDs: gh project list --owner ${esc(v.owner)}
|
|
90
|
-
# =============================================================================
|
|
91
|
-
|
|
92
|
-
# project_board:
|
|
93
|
-
# owner: "${esc(v.owner)}"
|
|
94
|
-
# project_number: 1
|
|
95
|
-
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
96
|
-
# field_ids:
|
|
97
|
-
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
310
|
+
feature_pattern: "milestone/{slug}"`;
|
|
98
311
|
|
|
99
|
-
|
|
100
|
-
# REVIEW (OPTIONAL) — context injected into review agent prompts.
|
|
101
|
-
# =============================================================================
|
|
102
|
-
|
|
103
|
-
# review:
|
|
104
|
-
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
105
|
-
# context: |
|
|
106
|
-
# Describe your repo structure and any unusual conventions here.
|
|
107
|
-
|
|
108
|
-
# =============================================================================
|
|
109
|
-
# VERIFICATION (OPTIONAL) — health checks for quality gate / validate.
|
|
110
|
-
# =============================================================================
|
|
111
|
-
|
|
112
|
-
# verification:
|
|
113
|
-
# health_endpoint: "https://api.example.com/health"
|
|
114
|
-
# health_patterns:
|
|
115
|
-
# - '"status": "ok"'
|
|
116
|
-
`;
|
|
312
|
+
const content = [header, ...OPTIONAL_SECTIONS.map((s) => s.block(v))].join("\n\n") + "\n";
|
|
117
313
|
// Atomic write: write to a temp file first, then rename into place.
|
|
118
314
|
// If the write fails (e.g. ENOSPC), the original file is untouched and
|
|
119
315
|
// any partial .tmp is cleaned up — no corrupt or missing forge.yaml.
|
|
@@ -129,6 +325,79 @@ branches:
|
|
|
129
325
|
return { todoCount };
|
|
130
326
|
}
|
|
131
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Backfill any of the 16 optional forge.yaml sections missing from an
|
|
330
|
+
* existing config file (#1982). Additive only — never edits, reorders, or
|
|
331
|
+
* removes existing content. Required sections, already-present optional
|
|
332
|
+
* sections, and any hand-edited content are left byte-for-byte untouched;
|
|
333
|
+
* only trailing whitespace at the very end of the file is trimmed before
|
|
334
|
+
* appending. Follows the same atomic temp+rename write pattern as
|
|
335
|
+
* writeForgeYaml() (ref: #1396 — a non-atomic write here could corrupt the
|
|
336
|
+
* user's forge.yaml on disk-full).
|
|
337
|
+
*
|
|
338
|
+
* A section counts as "present" if its top-level key appears either active
|
|
339
|
+
* (`key:` at column 0 — the user enabled it) or as the standard commented
|
|
340
|
+
* stub (`# key:`) anywhere in the file. Detection is intentionally strict
|
|
341
|
+
* about indentation (no arbitrary leading whitespace) so a same-named key
|
|
342
|
+
* nested under an unrelated section can never produce a false "present".
|
|
343
|
+
*
|
|
344
|
+
* @param {string} cwd - directory containing forge.yaml
|
|
345
|
+
* @returns {{ present: boolean, added: string[], alreadyPresent: string[] }}
|
|
346
|
+
* `present: false` means no forge.yaml exists at cwd — added/alreadyPresent
|
|
347
|
+
* are both empty; the caller should direct the user to `npx forgedock init`
|
|
348
|
+
* instead of migrate.
|
|
349
|
+
*/
|
|
350
|
+
export function backfillForgeYaml(cwd) {
|
|
351
|
+
const outputPath = join(cwd, "forge.yaml");
|
|
352
|
+
if (!existsSync(outputPath)) {
|
|
353
|
+
return { present: false, added: [], alreadyPresent: [] };
|
|
354
|
+
}
|
|
355
|
+
const raw = readFileSync(outputPath, "utf-8");
|
|
356
|
+
|
|
357
|
+
// Best-effort values for the blocks that interpolate project identity
|
|
358
|
+
// (repos/project_board examples) — read from the existing file's REQUIRED
|
|
359
|
+
// sections so backfilled examples reference this project rather than a
|
|
360
|
+
// generic placeholder. Falls back to placeholders if unset/unparseable;
|
|
361
|
+
// these only ever land inside commented-out example text.
|
|
362
|
+
const v = {
|
|
363
|
+
owner: raw.match(/^\s*owner:\s*"([^"]*)"/m)?.[1] || "your-org",
|
|
364
|
+
repo: raw.match(/^\s*repo:\s*"([^"]*)"/m)?.[1] || "your-repo",
|
|
365
|
+
stagingBranch: raw.match(/^\s*staging:\s*"([^"]*)"/m)?.[1] || "staging",
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const added = [];
|
|
369
|
+
const alreadyPresent = [];
|
|
370
|
+
const newBlocks = [];
|
|
371
|
+
for (const section of OPTIONAL_SECTIONS) {
|
|
372
|
+
const isActive = new RegExp(`^${section.key}:`, "m").test(raw);
|
|
373
|
+
const isStub = new RegExp(`^#\\s?${section.key}:`, "m").test(raw);
|
|
374
|
+
if (isActive || isStub) {
|
|
375
|
+
alreadyPresent.push(section.key);
|
|
376
|
+
} else {
|
|
377
|
+
added.push(section.key);
|
|
378
|
+
newBlocks.push(section.block(v));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (added.length === 0) {
|
|
383
|
+
return { present: true, added, alreadyPresent };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const trimmedRaw = raw.replace(/\s+$/, "");
|
|
387
|
+
const content = `${trimmedRaw}\n\n${newBlocks.join("\n\n")}\n`;
|
|
388
|
+
|
|
389
|
+
const tmpPath = outputPath + ".tmp";
|
|
390
|
+
try {
|
|
391
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
392
|
+
renameSync(tmpPath, outputPath);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { present: true, added, alreadyPresent };
|
|
399
|
+
}
|
|
400
|
+
|
|
132
401
|
/** Maps manual-flow value keys to their location in a ConfigDraft. */
|
|
133
402
|
const DRAFT_KEY_PATHS = {
|
|
134
403
|
owner: ["project", "owner"],
|
|
@@ -1254,9 +1523,24 @@ export async function forge(ctx) {
|
|
|
1254
1523
|
}
|
|
1255
1524
|
}
|
|
1256
1525
|
} else {
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1526
|
+
// Regular file not in manifest — could be a copy from a pre-manifest
|
|
1527
|
+
// ForgeDock version, or a user-owned file. Content-compare: if it
|
|
1528
|
+
// matches source exactly, adopt it into the manifest silently (it was
|
|
1529
|
+
// ours). If it differs, leave it untouched — it may be user-customized.
|
|
1530
|
+
let adopted = false;
|
|
1531
|
+
try {
|
|
1532
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1533
|
+
if (src.equals(dst)) {
|
|
1534
|
+
recordCopy(rel);
|
|
1535
|
+
skipped++;
|
|
1536
|
+
adopted = true;
|
|
1537
|
+
}
|
|
1538
|
+
} catch { /* readFile failure — fall through to warning */ }
|
|
1539
|
+
if (!adopted) {
|
|
1540
|
+
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
1541
|
+
w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
|
|
1542
|
+
skipped++;
|
|
1543
|
+
}
|
|
1260
1544
|
}
|
|
1261
1545
|
} catch (err) {
|
|
1262
1546
|
if (err.code !== "ENOENT") throw err;
|
package/commands/validate.md
CHANGED
|
@@ -147,10 +147,18 @@ INPUT_ISSUE=$(echo "$ARGUMENTS" | grep -oE '#?([0-9]+)' | grep -oE '[0-9]+' | he
|
|
|
147
147
|
GH_FLAG=$(yq -r '"-R " + .project.owner + "/" + .project.repo' forge.yaml 2>/dev/null || echo "")
|
|
148
148
|
|
|
149
149
|
if [ -n "$INPUT_ISSUE" ] && [ -n "$GH_FLAG" ]; then
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
# forge#1997: a bare `2>/dev/null || echo ""` here collapsed "fetch
|
|
151
|
+
# failed" and "fetch succeeded, issue has no labels" into the same empty
|
|
152
|
+
# string, so a failed fetch fell through to the "no needs-validation —
|
|
153
|
+
# no label transition needed" branch below, silently misreporting a
|
|
154
|
+
# transient API failure as a legitimate no-op. Use the same
|
|
155
|
+
# `if VAR=$(cmd); then ... else ... fi` idiom already applied to
|
|
156
|
+
# scripts/transition-label.sh (forge#1991) to keep the two outcomes
|
|
157
|
+
# distinguishable.
|
|
158
|
+
if ! ISSUE_LABELS=$(gh issue view "$INPUT_ISSUE" "$GH_FLAG" --json labels \
|
|
159
|
+
--jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
160
|
+
echo "WARNING: needs-validation label fetch failed (transient network error / rate limit?) for issue #$INPUT_ISSUE — cannot determine finding-lifecycle state. Skipping label transition." >&2
|
|
161
|
+
elif echo "$ISSUE_LABELS" | grep -q "needs-validation"; then
|
|
154
162
|
echo "Issue #$INPUT_ISSUE has needs-validation — applying verdict label transition..."
|
|
155
163
|
|
|
156
164
|
# Map /validate verdict to transition-label.sh verdict format
|
|
@@ -240,13 +240,21 @@ POST_REVIEW_LABELS=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels --jq '
|
|
|
240
240
|
**If `workflow:awaiting-merge` is present** (clean re-review case — the only branch where `review-pr.md`'s guard has already safely parked this PR): compute the bar.
|
|
241
241
|
|
|
242
242
|
```bash
|
|
243
|
+
# Trust filter: only reviews/comments from repo collaborators (OWNER/MEMBER/COLLABORATOR
|
|
244
|
+
# authorAssociation) can contribute to the auto-land bar. Unlike work-on.md Phase 7A's
|
|
245
|
+
# informational-only APPROVED: count (a summary-card/decision-record annotation, not a
|
|
246
|
+
# merge gate), this count directly drives `gh pr merge` below — so it must not trust
|
|
247
|
+
# unauthenticated signal. Any GitHub user can comment "APPROVED: ..." on a public PR;
|
|
248
|
+
# authorAssociation is GitHub's own repo-permission classification and cannot be spoofed
|
|
249
|
+
# by comment text. (Ref: forge#1976)
|
|
243
250
|
REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
|
|
244
|
-
--jq '[.reviews[].
|
|
251
|
+
--jq '[.reviews[] | select(.authorAssociation == "OWNER" or .authorAssociation == "MEMBER" or .authorAssociation == "COLLABORATOR") | .body // ""] +
|
|
252
|
+
[.comments[] | select(.authorAssociation == "OWNER" or .authorAssociation == "MEMBER" or .authorAssociation == "COLLABORATOR") | .body // ""] | .[]')
|
|
245
253
|
APPROVED_COUNT=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || true); APPROVED_COUNT=${APPROVED_COUNT:-0}
|
|
246
254
|
```
|
|
247
255
|
|
|
248
256
|
**Auto-land bar** (per #1809 Q1) — BOTH conditions required:
|
|
249
|
-
1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments (same counting convention as `work-on.md` Phase 7A).
|
|
257
|
+
1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments from repo collaborators (`OWNER`/`MEMBER`/`COLLABORATOR` authorAssociation only — see trust filter above; same counting convention as `work-on.md` Phase 7A).
|
|
250
258
|
2. `GATE_PASSED = true` from this remediation's own Phase M3 quality-gate loop.
|
|
251
259
|
|
|
252
260
|
**If the bar is met**:
|
package/commands/work-on.md
CHANGED
|
@@ -382,7 +382,16 @@ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
|
|
|
382
382
|
echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
|
|
383
383
|
ADAPTIVE_ENABLED=false
|
|
384
384
|
fi
|
|
385
|
-
UNIVERSAL_DIR="${FORGEDOCK_HOME:-$
|
|
385
|
+
UNIVERSAL_DIR="${FORGEDOCK_HOME:-$REPO_PATH}/scripts"
|
|
386
|
+
# NOTE: never resolve this via `which` or `find` — universal scripts are
|
|
387
|
+
# repo-relative, not installed on $PATH, so a PATH lookup always misses.
|
|
388
|
+
# REPO_PATH is already resolved from forge.yaml → paths.root earlier in
|
|
389
|
+
# Phase 0, so it is the deterministic fallback when FORGEDOCK_HOME is unset.
|
|
390
|
+
# Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
|
|
391
|
+
# locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
|
|
392
|
+
# does not exist, resolve_script() falls through to Tier 4 (prose) below,
|
|
393
|
+
# which is always safe and available. A missing script is never a reason
|
|
394
|
+
# to search the filesystem. <!-- Added: forge#1984 -->
|
|
386
395
|
|
|
387
396
|
resolve_script() {
|
|
388
397
|
local operation="$1"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
4
4
|
"description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
package/scripts/danger-zones.mjs
CHANGED
|
@@ -349,7 +349,9 @@ function collectCommits(repoPath, sinceDate) {
|
|
|
349
349
|
const reason = result.error
|
|
350
350
|
? result.error.message
|
|
351
351
|
: `exit code ${result.status}${result.signal ? ` (signal ${result.signal})` : ''}`;
|
|
352
|
-
|
|
352
|
+
const stderrDetail =
|
|
353
|
+
result.stderr && result.stderr.trim() ? ` — ${result.stderr.trim()}` : '';
|
|
354
|
+
log(`WARNING: git log failed — ${reason}${stderrDetail}`);
|
|
353
355
|
return [];
|
|
354
356
|
}
|
|
355
357
|
|
|
@@ -83,8 +83,25 @@ if [ "${1:-}" = "--validate" ]; then
|
|
|
83
83
|
fi
|
|
84
84
|
|
|
85
85
|
# Check if issue carries needs-validation (idempotent gate)
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
#
|
|
87
|
+
# forge#1991: a bare `2>/dev/null || echo ""` here collapsed "fetch
|
|
88
|
+
# failed" and "fetch succeeded, issue has no labels" into the same empty
|
|
89
|
+
# string, so a failed fetch fell through to the "no needs-validation —
|
|
90
|
+
# no action taken (idempotent)" branch below and exited 0, silently
|
|
91
|
+
# misreporting a transient API failure as successful idempotent no-op.
|
|
92
|
+
# Use the same `if VAR=$(cmd); then ... else ... fi` idiom already
|
|
93
|
+
# applied to the workflow-mode label fetches (forge#1977/#1988/#1990) to
|
|
94
|
+
# keep the two outcomes distinguishable. This block has no
|
|
95
|
+
# TRANSITION_EXIT_CODE deferred-exit convention (that only exists in the
|
|
96
|
+
# workflow-mode block below), so failure exits 1 directly here, matching
|
|
97
|
+
# this script's documented exit codes (0 = success, 1 = error).
|
|
98
|
+
if CURRENT_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
|
|
99
|
+
--jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
100
|
+
: # fetch succeeded; CURRENT_LABELS may legitimately be an empty string
|
|
101
|
+
else
|
|
102
|
+
echo "ERROR: needs-validation label fetch failed (transient network error / rate limit?) for issue #$ISSUE_NUMBER — cannot determine finding-lifecycle state. Aborting without action." >&2
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
88
105
|
|
|
89
106
|
if ! echo "$CURRENT_LABELS" | grep -q "needs-validation"; then
|
|
90
107
|
echo "OK: Issue #$ISSUE_NUMBER does not have needs-validation — no action taken (idempotent)"
|
|
@@ -282,9 +299,27 @@ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --add-label "$EFFECTIVE_LABEL"
|
|
|
282
299
|
# have doesn't need removing anyway — so intersecting against the issue's
|
|
283
300
|
# current labels sidesteps the all-or-nothing failure mode entirely instead
|
|
284
301
|
# of relying on `|| true` to mask it.
|
|
302
|
+
#
|
|
303
|
+
# forge#1990: this fetch itself can fail (transient network error, rate
|
|
304
|
+
# limit) independently of whether the issue actually has any stale labels.
|
|
305
|
+
# A bare `2>/dev/null || echo ""` collapsed "fetch failed" and "fetch
|
|
306
|
+
# succeeded, issue has no labels" into the same empty string — so a failed
|
|
307
|
+
# fetch here silently skipped the ENTIRE removal/verification block below
|
|
308
|
+
# (not just its post-hoc confirmation) while the script still reported `OK`
|
|
309
|
+
# on exit 0. Use the same `if VAR=$(cmd); then ... else ... fi` idiom
|
|
310
|
+
# already applied to the post-removal verification fetch below (forge#1977/
|
|
311
|
+
# #1988) to keep the two outcomes distinguishable, and defer a non-zero
|
|
312
|
+
# exit via TRANSITION_EXIT_CODE (forge#1929 convention) instead of masking
|
|
313
|
+
# the failure as success.
|
|
285
314
|
# ---------------------------------------------------------------------------
|
|
286
|
-
CURRENT_ISSUE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
|
|
287
|
-
--jq '[.labels[].name] | join(",")' 2>/dev/null
|
|
315
|
+
if CURRENT_ISSUE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
|
|
316
|
+
--jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
317
|
+
: # fetch succeeded; CURRENT_ISSUE_LABELS may legitimately be an empty string
|
|
318
|
+
else
|
|
319
|
+
echo "ERROR: pre-removal label fetch failed (transient network error / rate limit?) for issue #$ISSUE_NUMBER — cannot determine which stale workflow:* labels to remove. Skipping removal to avoid acting on incomplete data." >&2
|
|
320
|
+
CURRENT_ISSUE_LABELS=""
|
|
321
|
+
TRANSITION_EXIT_CODE=1
|
|
322
|
+
fi
|
|
288
323
|
|
|
289
324
|
TO_REMOVE=""
|
|
290
325
|
IFS=',' read -ra REMOVE_CANDIDATES <<< "$REMOVE_LABELS"
|
|
@@ -327,35 +362,52 @@ if [ -n "$TO_REMOVE" ]; then
|
|
|
327
362
|
# step in the one case (persistent removal failure) it's most important
|
|
328
363
|
# for the caller to see a consistent, fully-applied label state.
|
|
329
364
|
# -------------------------------------------------------------------------
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
365
|
+
# forge#1977: the fetch itself can fail (transient network error, rate
|
|
366
|
+
# limit) independently of whether the label was actually removed. A bare
|
|
367
|
+
# `2>/dev/null || echo ""` collapses "fetch failed" and "fetch succeeded,
|
|
368
|
+
# label absent" into the same empty string, so a failed verification call
|
|
369
|
+
# was previously indistinguishable from a confirmed-clean result — masking
|
|
370
|
+
# the exact failure mode this block exists to catch. Use `if VAR=$(cmd);
|
|
371
|
+
# then ... else ... fi` (set -e-safe: command substitution failure inside
|
|
372
|
+
# an `if` condition does not trigger `set -e`) to keep the two outcomes
|
|
373
|
+
# distinguishable, and treat a failed fetch as "still present" so it flows
|
|
374
|
+
# into the existing retry / fail-loud path below instead of silently
|
|
375
|
+
# reporting success.
|
|
376
|
+
if POST_REMOVE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
|
|
377
|
+
--jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
378
|
+
STILL_PRESENT=""
|
|
379
|
+
IFS=',' read -ra TO_REMOVE_CHECK <<< "$TO_REMOVE"
|
|
380
|
+
for candidate in "${TO_REMOVE_CHECK[@]}"; do
|
|
381
|
+
case ",$POST_REMOVE_LABELS," in
|
|
382
|
+
*",$candidate,"*)
|
|
383
|
+
STILL_PRESENT="${STILL_PRESENT:+$STILL_PRESENT,}$candidate"
|
|
384
|
+
;;
|
|
385
|
+
esac
|
|
386
|
+
done
|
|
387
|
+
else
|
|
388
|
+
echo "WARNING: post-removal verification API call failed (transient network error / rate limit?) — cannot confirm removal, treating ($TO_REMOVE) as unverified..." >&2
|
|
389
|
+
STILL_PRESENT="$TO_REMOVE"
|
|
390
|
+
fi
|
|
342
391
|
|
|
343
392
|
if [ -n "$STILL_PRESENT" ]; then
|
|
344
393
|
echo "WARNING: label removal did not take effect for ($STILL_PRESENT) — retrying once..." >&2
|
|
345
394
|
gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$STILL_PRESENT" 2>/dev/null || true
|
|
346
395
|
|
|
347
|
-
POST_RETRY_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
|
|
348
|
-
--jq '[.labels[].name] | join(",")' 2>/dev/null
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
396
|
+
if POST_RETRY_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
|
|
397
|
+
--jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
398
|
+
STILL_PRESENT_AFTER_RETRY=""
|
|
399
|
+
IFS=',' read -ra RETRY_CHECK <<< "$STILL_PRESENT"
|
|
400
|
+
for candidate in "${RETRY_CHECK[@]}"; do
|
|
401
|
+
case ",$POST_RETRY_LABELS," in
|
|
402
|
+
*",$candidate,"*)
|
|
403
|
+
STILL_PRESENT_AFTER_RETRY="${STILL_PRESENT_AFTER_RETRY:+$STILL_PRESENT_AFTER_RETRY,}$candidate"
|
|
404
|
+
;;
|
|
405
|
+
esac
|
|
406
|
+
done
|
|
407
|
+
else
|
|
408
|
+
echo "WARNING: post-retry verification API call also failed (transient network error / rate limit?) — cannot confirm removal after retry, treating ($STILL_PRESENT) as unverified..." >&2
|
|
409
|
+
STILL_PRESENT_AFTER_RETRY="$STILL_PRESENT"
|
|
410
|
+
fi
|
|
359
411
|
|
|
360
412
|
if [ -n "$STILL_PRESENT_AFTER_RETRY" ]; then
|
|
361
413
|
echo "ERROR: failed to remove stale label(s) ($STILL_PRESENT_AFTER_RETRY) from issue #$ISSUE_NUMBER after retry — label state machine now inconsistent (issue carries both '$EFFECTIVE_LABEL' and the stale label(s) above)." >&2
|