forgedock 1.36.0 → 1.37.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.
Files changed (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -12,7 +12,8 @@
12
12
  # FAILED the run executed and failed
13
13
  # RAN the run executed and passed
14
14
  # RAN_NOTHING the runner exercised nothing
15
- # UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure)
15
+ # UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure, or head failed
16
+ # only on a missing host module/command such as `No module named pytest`; forge#4655)
16
17
  # SKIPPED base was not run because head did not PASS (never a missing file: head's new test files are overlaid)
17
18
  # Never prints a verdict on success of cleanup: the base worktree is removed on every exit path (trap + explicit remove).
18
19
  # Callers map: HEAD=RAN BASE=FAILED -> proof; BASE=RAN -> passes on base (gate FAIL); anything else that is not a proof -> DEGRADED/FAIL.
@@ -54,7 +55,23 @@ run_in() {
54
55
  else ( cd "$dir" && bash -c "$CMD" ) >"$out" 2>&1; rc=$?; fi
55
56
  case "$rc" in 124|126|127) echo UNRUNNABLE; return ;; esac
56
57
  cls=$(bash "$CLS" "$rc" "$out" 2>/dev/null)
57
- case "$cls" in FAILED|RAN|RAN_NOTHING) echo "$cls" ;; *) echo UNRUNNABLE ;; esac
58
+ case "$cls" in FAILED|RAN|RAN_NOTHING) ;; *) echo UNRUNNABLE; return ;; esac
59
+ # forge#4655: a head run that failed only because the host lacks an interpreter module or command (No module named
60
+ # pytest, X: command not found) is the environment, not the change: UNRUNNABLE. A module the repo itself tracks stays FAILED.
61
+ if [ "$cls" = "FAILED" ] && [ "$dir" = "$REPO" ] && host_missing "$out"; then echo UNRUNNABLE; return; fi
62
+ echo "$cls"
63
+ }
64
+
65
+ # host_missing <output_file>: true when the output names a missing third-party python module or a missing command
66
+ host_missing() {
67
+ local f="$1" name
68
+ name=$(sed -n "s/.*No module named '\{0,1\}\([A-Za-z0-9_-]\{1,\}\).*/\1/p" "$f" | head -1)
69
+ if [ -n "$name" ]; then
70
+ git -C "$REPO" ls-files 2>/dev/null | tr '/' '\n' | grep -qxE "$name(\.py)?" && return 1
71
+ return 0
72
+ fi
73
+ name=$(sed -n 's/.*[ :]\([A-Za-z0-9_.-][A-Za-z0-9_.-]*\): command not found.*/\1/p' "$f" | head -1)
74
+ [ -n "$name" ]
58
75
  }
59
76
 
60
77
  HEAD_RES=$(run_in "$REPO")
@@ -38,6 +38,8 @@ mkdir -p "$R2/tests"; printf 'echo "1 passed"\n' > "$R2/tests/t.sh"; git -C "$R2
38
38
  check "passes on base" "BASE=RAN HEAD=RAN" "$R2" "bash tests/t.sh"
39
39
 
40
40
  # 3. head fails: base is not run
41
+ check "head missing host module is UNRUNNABLE, not a failure (#4655)" "BASE=SKIPPED HEAD=UNRUNNABLE" "$R1" "echo \"/usr/bin/python3: No module named 'pytest_nonexistent_x'\" >&2; exit 1"
42
+ check "head unquoted No module named pytest is UNRUNNABLE (#4655)" "BASE=SKIPPED HEAD=UNRUNNABLE" "$R1" "echo '/usr/bin/python3: No module named pytest' >&2; exit 1"
41
43
  check "head failure skips base" "BASE=SKIPPED HEAD=FAILED" "$R1" "bash app.sh; exit 1"
42
44
 
43
45
  # 4. RAN_NOTHING on head
@@ -218,7 +218,8 @@ export function createBacklog(plan, { io, repo, config = {}, base }) {
218
218
 
219
219
  // Labels: create only the missing ones (never overwrite an existing label's colour or description).
220
220
  const haveLabels = new Set((json(["label", "list", "-R", repo, "--limit", "1000", "--json", "name"]) ?? []).map((l) => l.name));
221
- const wanted = new Set(plan.issues.flatMap((it) => [...(it.labels ?? []), priorityLabel(it)]).filter(Boolean));
221
+ const issueLabels = (it) => [...(it.labels ?? []), priorityLabel(it), ...(isNeedsHuman(it) ? ["needs-human:external"] : [])].filter(Boolean); // #4660
222
+ const wanted = new Set(plan.issues.flatMap(issueLabels));
222
223
  for (const l of wanted) if (!haveLabels.has(l)) gh(["label", "create", l, "-R", repo]);
223
224
 
224
225
  // Milestones in build order, each with its lane branch.
@@ -253,7 +254,7 @@ export function createBacklog(plan, { io, repo, config = {}, base }) {
253
254
  return;
254
255
  }
255
256
  const args = ["issue", "create", "-R", repo, "--title", it.title, "--body-file", "-", "--milestone", milestoneTitle.get(it.milestone)];
256
- for (const l of [...(it.labels ?? []), priorityLabel(it)].filter(Boolean)) args.push("--label", l);
257
+ for (const l of new Set(issueLabels(it))) args.push("--label", l);
257
258
  const body = finalBody(it);
258
259
  const out = gh(args, body);
259
260
  const number = parseIssueNumber(out);
@@ -19,6 +19,8 @@
19
19
  * missing-section, missing-type-tag, missing-parent, missing-labels, unknown-label, code-depends-on-needs-human,
20
20
  * missing-design-spec (a `ui` issue lacks a design-spec item), hero-not-routed (a `hero` issue never names `/design`),
21
21
  * missing-needs-human-section (a needs-human issue lacks setup steps, env var names or secret handling) (#4176).
22
+ * Warns (never fails; exit stays 0): deploy-last (#4665: every milestone holding deploy workflows comes after the feature
23
+ * milestones, so nothing can be previewed or deployed until they all ship).
22
24
  * `Depends on` lines are read with the engine's own parser (bin/engine/phases.mjs parseDependsOn) after placeholder
23
25
  * resolution, so the plan and engine admission agree on what blocks what.
24
26
  *
@@ -35,8 +37,10 @@
35
37
  import { readFileSync } from "node:fs";
36
38
  import { fileURLToPath } from "node:url";
37
39
  import { parseDependsOn } from "../bin/engine/phases.mjs";
40
+ import { parseGates } from "./readiness.mjs";
38
41
 
39
42
  const NEEDS_HUMAN_LABEL = "needs-human";
43
+ const EXTERNAL_LABEL = "needs-human:external"; // #4660: external prerequisite; its `gates:` line names what it blocks
40
44
  const TYPE_TAG_RE = /\[type:(?:api|unit|e2e|manual)\]/;
41
45
  const CHECKBOX_RE = /^[ \t]*[-*+][ \t]+\[[ xX]\]/;
42
46
  const PLACEHOLDER_RE = /#\{\{([^{}]*)\}\}/g;
@@ -66,13 +70,17 @@ const NEEDS_HUMAN_PARTS = [
66
70
  ["env var name (e.g. `API_KEY`)", /`[A-Z][A-Z0-9]*_[A-Z0-9_]+`/],
67
71
  ];
68
72
 
73
+ // #4665: an issue holds deploy work when its title or body names a deploy workflow or a staging environment definition.
74
+ const DEPLOY_RE = /\.github\/workflows\/[^\s`'")]*deploy|\bdeploy(?:ment)? workflow\b|\bstaging environment\b/i;
75
+ const isDeployIssue = (it) => DEPLOY_RE.test(`${it?.title ?? ""}\n${it?.body ?? ""}`);
76
+
69
77
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
70
78
  const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
71
79
  const normHeading = (h) => h.replace(/\([^)]*\)\s*$/, "").trim().toLowerCase();
72
80
 
73
81
  /** True when the issue is a needs-human issue (credential, account or external input). */
74
82
  export function isNeedsHuman(issue) {
75
- return issue?.needsHuman === true || (Array.isArray(issue?.labels) && issue.labels.includes(NEEDS_HUMAN_LABEL));
83
+ return issue?.needsHuman === true || (Array.isArray(issue?.labels) && (issue.labels.includes(NEEDS_HUMAN_LABEL) || issue.labels.includes(EXTERNAL_LABEL)));
76
84
  }
77
85
 
78
86
  /**
@@ -175,7 +183,7 @@ function findCycles(issues, byKey) {
175
183
 
176
184
  /**
177
185
  * Validate a backlog plan. Never throws on bad input; structural problems come back as `invalid-plan` errors.
178
- * @returns {{ ok: boolean, errors: Array<{ issue: string|null, code: string, message: string }> }}
186
+ * @returns {{ ok: boolean, errors: Array<{ issue: string|null, code: string, message: string }>, warnings?: Array<{ issue: string|null, code: string, message: string }> }}
179
187
  */
180
188
  export function validatePlan(plan) {
181
189
  const structural = checkStructure(plan);
@@ -275,12 +283,30 @@ export function validatePlan(plan) {
275
283
  if (isNeedsHuman(it)) {
276
284
  const lacking = NEEDS_HUMAN_PARTS.filter(([, re]) => !re.test(it.body)).map(([name]) => name);
277
285
  if (lacking.length) err(it.key, "missing-needs-human-section", `needs-human issue lacks: ${lacking.join(", ")}`);
286
+ if (!parseGates(it.body).length) {
287
+ err(it.key, "missing-gates", "needs-human issue has no valid `gates:` line (preview|staging-deploy|prod-deploy|feature-flag:<name>)");
288
+ }
278
289
  }
279
290
  }
280
- return { ok: errors.length === 0, errors };
291
+ return { ok: errors.length === 0, errors, warnings: deployWarnings(issues, milestones) };
292
+ }
293
+
294
+ /** #4665: `deploy-last` when deploy work exists but none of it sits in milestones 1-2 (needs >= 3 milestones). */
295
+ function deployWarnings(issues, milestones) {
296
+ const index = new Map(milestones.map((m, i) => [m.key, i]));
297
+ const deploy = issues.filter(isDeployIssue);
298
+ if (!deploy.length || milestones.length < 3) return [];
299
+ const at = deploy.map((it) => index.get(it.milestone));
300
+ if (at.some((i) => i <= 1)) return [];
301
+ const where = [...new Set(at.map((i) => milestones[i]?.key).filter(Boolean))].join(", ");
302
+ return [{
303
+ issue: deploy[0].key, code: "deploy-last",
304
+ message: `deploy workflows are only in ${where ? `milestone ${where}` : "later milestones"}, none in the first two; put a thin deployable slice (CI deploy workflow, staging environment, seed) in the first or second milestone`,
305
+ }];
281
306
  }
282
307
 
283
308
  /** One `<KEY>: [code] message` line per error; plan-level errors use `(plan)`. */
309
+ export const formatWarnings = (warnings) => formatErrors(warnings);
284
310
  export function formatErrors(errors) {
285
311
  return errors.map((e) => `${e.issue ?? "(plan)"}: [${e.code}] ${e.message}`).join("\n");
286
312
  }
@@ -305,7 +331,8 @@ function main(argv) {
305
331
  process.stderr.write(`bootstrap-validate: invalid JSON: ${e.message}\n`);
306
332
  return 2;
307
333
  }
308
- const { ok, errors } = validatePlan(plan);
334
+ const { ok, errors, warnings = [] } = validatePlan(plan);
335
+ if (warnings.length) process.stderr.write(formatErrors(warnings).replace(/^/gm, "warning: ") + "\n");
309
336
  if (!ok) {
310
337
  process.stderr.write(formatErrors(errors) + "\n");
311
338
  process.stderr.write(`bootstrap-validate: ${errors.length} error(s)\n`);
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # file-deferred-followups.sh — file ALL deferred out-of-scope items of one build as ONE follow-up issue.
2
+ # file-deferred-followups.sh — file the deferred out-of-scope items of one build as ONE follow-up issue per class.
3
3
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
@@ -7,14 +7,29 @@
7
7
  # that stay deferred: trusted lookup (claim marker <!-- FORGE:DEFERRED_ISSUE: #M --> on the parent, then a state-all search) of a follow-up carrying
8
8
  # <!-- FORGE:DEFERRED_FROM: #N -->, append the missing items (exact path match; a closed one is reopened), or create ONE issue
9
9
  # with a checklist, claim it on the parent and re-check once for a concurrent lower-numbered duplicate.
10
+ # Items are grouped by class (engineering | post-deploy | external | ops, #4664): one follow-up per class per parent, each
11
+ # labelled `followup:<class>`, carrying `<!-- FORGE:DEFERRED_CLASS: <class> -->` (absent = engineering, for older issues) and the
12
+ # parent's milestone (a shipped/closed one: the next open milestone by due date, else FORGE_FOLLOWUPS_DEFAULT_MILESTONE).
10
13
  #
11
14
  # Usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]
12
- # items TSV, one per line: <path> TAB <why> TAB <known-flaw flag: known-flaw|1|yes|true, else empty>
15
+ # items TSV, one per line: <path|-> TAB <why> TAB <known-flaw flag: known-flaw|1|yes|true, else empty> [TAB <class> [TAB <label>]]
16
+ # Column 1 must look like a path (any legal path characters; spaced values need a `/` or extension) or be `-` (a requirement-level item): requirement text or an `R<n>:` prefix there is a usage
17
+ # error (exit 2, DEFERRED_TSV=INVALID, nothing filed). The label (default: the path) is the item's identity in the checklist
18
+ # and in the stdout rows; repeated `R<n>: R<m>:` prefixes in it collapse to the first. Class: column 4, else the why's
19
+ # `post-deploy:|external:|ops:` prefix, else engineering.
20
+ # #4724 buckets: a why carrying `[bucket:<kind>]` is routed by it: `owner` is always class external (never the rollup),
21
+ # `new-issue` engineering, `verify-now` (the parent is already on the default branch) its own per-parent follow-up class
22
+ # `verify-now` that the gate releases at once, and `post-deploy`/`date` go to the ONE open rollup of the release lane.
23
+ # The rollup (class post-deploy) is found by `<!-- FORGE:POSTDEPLOY_ROLLUP: <lane> -->`, not by parent: every lane appends
24
+ # its items under a `### #<root parent>` heading (a follow-up of a follow-up is filed under the root parent), deduplicated
25
+ # on normalized text across parents. Env: FORGE_RELEASE_LANE (the deploy lane, default `default`), FORGE_RELEASE_SHA (informational).
13
26
  # A post-deploy why starts `post-deploy: [check:<id>] ...` (#4626); the token is kept verbatim in the checklist line.
14
27
  # Env: DRY_RUN (non-empty: no create/edit, issue number 0), FORGE_TRAIL_TRUSTED_ASSOCIATIONS (default
15
- # OWNER,MEMBER,COLLABORATOR), FORGE_TRAIL_TRUSTED_LOGINS (default empty), FORGE_DEFERRED_RETRY_SLEEP (secs).
16
- # Stdout: `path<TAB>issue` per item, then DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M filed=N.
17
- # Exit: 0 ok | 2 usage | 3 lookup failed (DEFERRED_LOOKUP=FAILED) | 4 create/append failed (DEFERRED_FILE=FAILED).
28
+ # OWNER,MEMBER,COLLABORATOR), FORGE_TRAIL_TRUSTED_LOGINS (default empty), FORGE_DEFERRED_RETRY_SLEEP (secs),
29
+ # FORGE_FOLLOWUP_MILESTONE (the parent's milestone title; default: read from the parent), FORGE_FOLLOWUPS_DEFAULT_MILESTONE
30
+ # (forge.yaml followups.default_milestone), FORGE_EXTERNAL_PREREQ (issue number an external follow-up links to).
31
+ # Stdout: `identity<TAB>issue` per item, then DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M[,#M2] filed=N classes=a,b buckets=kind:N,...
32
+ # Exit: 0 ok | 2 usage or malformed TSV row | 3 lookup failed (DEFERRED_LOOKUP=FAILED) | 4 create/append failed (DEFERRED_FILE=FAILED).
18
33
  # A failed lookup is never "none found". bash 3.2 portable.
19
34
 
20
35
  set -uo pipefail
@@ -38,37 +53,72 @@ TMP=$(mktemp -d "${TMPDIR:-/tmp}/deferred-followups.XXXXXX") || exit 2
38
53
  trap 'rm -rf "$TMP"' EXIT
39
54
  if [ "$ITEMS" = "-" ]; then cat > "$TMP/items.raw"; else cat "$ITEMS" > "$TMP/items.raw"; fi
40
55
 
41
- # Normalise: drop blank lines and duplicate paths (first wins), strip backticks from the path.
56
+ # Normalise: validate each row, drop blank lines and duplicate identities (first wins), strip backticks.
42
57
  # Internal file uses a non-whitespace separator (US, \x1f): a tab IFS collapses an empty `why` and drops the known-flaw flag.
58
+ # Fields: identity, why, flag, path (`-` when none), class.
43
59
  SEP=$(printf '\037')
60
+ CLASSES="engineering post-deploy external ops verify-now"
44
61
  : > "$TMP/items"
62
+ BAD=0; ROW=0
63
+ bad_row() { BAD=$((BAD+1)); echo "file-deferred-followups: row $ROW: $1" >&2; }
45
64
  while IFS= read -r line || [ -n "$line" ]; do
46
- # Split on TAB by hand: `IFS=$'\t' read` would collapse an empty why field and shift the flag into it.
47
- p="${line%%$'\t'*}"; rest=""; w=""; k=""
48
- case "$line" in *$'\t'*) rest="${line#*$'\t'}"; w="${rest%%$'\t'*}"; case "$rest" in *$'\t'*) k="${rest#*$'\t'}" ;; esac ;; esac
49
- p=$(printf '%s' "${p:-}" | tr -d '`' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
50
- [ -n "$p" ] || continue
51
- if cut -d "$SEP" -f1 "$TMP/items" | grep -qxF -- "$p"; then continue; fi
65
+ ROW=$((ROW+1))
66
+ # Split on TAB by hand: `IFS=$'\t' read` would collapse an empty field and shift the flag into it.
67
+ f=(); rest="$line"; i=0
68
+ while [ $i -lt 5 ]; do
69
+ case "$rest" in *$'\t'*) f[$i]="${rest%%$'\t'*}"; rest="${rest#*$'\t'}" ;; *) f[$i]="$rest"; rest="" ;; esac
70
+ i=$((i+1))
71
+ done
72
+ trim() { printf '%s' "${1:-}" | tr -d '`' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; }
73
+ p=$(trim "${f[0]}"); w="${f[1]}"; k="${f[2]}"; c=$(trim "${f[3]}"); lab=$(trim "${f[4]}")
74
+ [ -n "$p" ] || [ -n "$lab" ] || continue
75
+ # Column 1: a path or the literal `-`. Requirement text (spaces, an `R<n>:` prefix) belongs in the label column.
76
+ if [ "$p" != "-" ]; then
77
+ if printf '%s' "$p" | grep -qE '^R[0-9]+:'; then bad_row "column 1 starts with a requirement id (\`$p\`); put it in the label column and use \`-\` or a path"; continue; fi
78
+ # Legal path characters (parentheses, =, #, :, ...) pass. Reject control chars, and whitespace values that are prose:
79
+ # a spaced path needs a `/` or a file extension (`docs/My Notes.md`); `add a saved mapping` has neither.
80
+ if printf '%s' "$p" | LC_ALL=C grep -q '[[:cntrl:]]'; then bad_row "column 1 is not a path (\`$p\`)"; continue; fi
81
+ if [[ "$p" =~ [[:space:]] ]] && ! [[ "$p" == */* || "$p" =~ \.[A-Za-z0-9]+$ ]]; then bad_row "column 1 is not a path (\`$p\`)"; continue; fi
82
+ fi
83
+ # Identity: the label, else the path. Repeated `R1: R2:` prefixes collapse to the first.
84
+ id="${lab:-$p}"
85
+ while [[ "$id" =~ ^(R[0-9]+:)[[:space:]]*R[0-9]+:[[:space:]]*(.*)$ ]]; do id="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"; done
86
+ if [ "$id" = "-" ]; then bad_row "no path and no label"; continue; fi
87
+ w=$(printf '%s' "$w" | tr '\r\n' ' ')
88
+ if [ -z "$c" ]; then
89
+ case "$w" in post-deploy:*) c="post-deploy" ;; external:*) c="external" ;; ops:*) c="ops" ;; *) c="engineering" ;; esac
90
+ fi
91
+ case " $CLASSES " in *" $c "*) ;; *) bad_row "unknown class \`$c\` (engineering|post-deploy|external|ops)"; continue ;; esac
92
+ # #4724: the bucket token decides the class: owner actions never join the post-deploy rollup, verify-now needs no release.
93
+ case "$w" in
94
+ *"[bucket:owner]"*) [ "$c" = "ops" ] || c="external" ;;
95
+ *"[bucket:new-issue]"*) [ "$c" = "post-deploy" ] && c="engineering" ;;
96
+ *"[bucket:verify-now]"*) [ "$c" = "post-deploy" ] && c="verify-now" ;;
97
+ esac
98
+ if cut -d "$SEP" -f1 "$TMP/items" | grep -qxF -- "$id"; then continue; fi
52
99
  case "${k:-}" in known-flaw|1|yes|true) k="known-flaw" ;; *) k="" ;; esac
53
- w=$(printf '%s' "${w:-}" | tr '\r\n' ' ')
54
- printf '%s%s%s%s%s\n' "$p" "$SEP" "$w" "$SEP" "$k" >> "$TMP/items"
100
+ printf '%s%s%s%s%s%s%s%s%s\n' "$id" "$SEP" "$w" "$SEP" "$k" "$SEP" "${p:--}" "$SEP" "$c" >> "$TMP/items"
55
101
  done < "$TMP/items.raw"
102
+ if [ "$BAD" -gt 0 ]; then echo "DEFERRED_TSV=INVALID"; exit 2; fi
56
103
  TOTAL=$(wc -l < "$TMP/items" | tr -d ' ')
57
104
 
58
105
  MARKER="<!-- FORGE:DEFERRED_FROM: #${PARENT} -->"
59
106
  TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
60
107
  TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
61
108
 
62
- finish() { # $1 = batched_into number or empty, $2 = filed count
63
- local into="none"; [ -n "$1" ] && into="#$1"
64
- local line="DEFERRED_TELEMETRY deferred_items=${TOTAL} folded=${FOLDED} batched_into=${into} filed=$2"
109
+ finish() { # $1 = batched_into numbers (space separated) or empty, $2 = filed count
110
+ local into="none" bare="none"
111
+ if [ -n "$1" ]; then into=$(printf '%s\n' $1 | sed 's/^/#/' | paste -sd, -); bare=$(printf '%s\n' $1 | paste -sd, -); fi
112
+ local cls; cls=$(cut -d "$SEP" -f5 "$TMP/items" | sort -u | paste -sd, -)
113
+ local bks; bks=$(awk -F"$SEP" '{ b = "none"; if (match($2, /\[bucket:[a-z-]+\]/)) b = substr($2, RSTART + 8, RLENGTH - 9); n[b]++ } END { for (b in n) printf "%s:%d\n", b, n[b] }' "$TMP/items" | sort | paste -sd, -)
114
+ local line="DEFERRED_TELEMETRY deferred_items=${TOTAL} folded=${FOLDED} batched_into=${into} filed=$2 classes=${cls:-none} buckets=${bks:-none}"
65
115
  echo "$line"
66
116
  # #3834 emitter (scripts/lib/telemetry.sh): optional, never blocks, never changes stdout or the exit code.
67
117
  local d; d=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
68
118
  if [ -f "$d/lib/telemetry.sh" ]; then
69
119
  FORGE_ISSUE="${FORGE_ISSUE:-$PARENT}" FORGE_REPO="${FORGE_REPO:-$REPO}" \
70
120
  bash "$d/lib/telemetry.sh" deferred_followups deferred_items="$TOTAL" folded="$FOLDED" \
71
- batched_into="${1:-none}" filed="$2" >/dev/null 2>&1 </dev/null || true
121
+ batched_into="$bare" filed="$2" classes="${cls:-none}" buckets="${bks:-none}" >/dev/null 2>&1 </dev/null || true
72
122
  fi
73
123
  }
74
124
 
@@ -80,7 +130,7 @@ JQ_PRE='def lst: split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length >
80
130
  def trusted: ((.author_association // "") as $x | ($assoc | lst) | index($x) != null)
81
131
  or ((.user.type // "") == "Bot")
82
132
  or ((.user.login // "") as $x | ($logins | lst) | index($x) != null);'
83
- CLAIM_RE='^\s*<!-- FORGE:DEFERRED_ISSUE: #(?<n>[0-9]+) -->'
133
+ CLAIM_RE='^\s*<!-- FORGE:DEFERRED_ISSUE: #(?<n>[0-9]+)(?: class=[a-z-]+)? -->'
84
134
  claim_nums() {
85
135
  gh api --paginate "repos/${REPO}/issues/${PARENT}/comments" 2>/dev/null \
86
136
  | jq -s -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg re "$CLAIM_RE" "$JQ_PRE"'
@@ -92,12 +142,13 @@ search_nums() {
92
142
  | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
93
143
  [.items[] | select(trusted) | select((.body // "") | contains($m)) | .number] | sort | .[]' 2>/dev/null
94
144
  }
95
- verify_num() { # number -> `number<TAB>state` when a trusted, non-PR issue carries the marker; empty when not (a 404 is "not it")
145
+ verify_num() { # number -> `number<TAB>state` when a trusted, non-PR issue of class $CLS carries the marker; empty when not (a 404 is "not it")
96
146
  local out rc
97
147
  out=$(gh api "repos/${REPO}/issues/$1" 2>"$TMP/verr"); rc=$?
98
148
  if [ "$rc" -ne 0 ]; then grep -qE '404|Not Found|410' "$TMP/verr" 2>/dev/null && return 0; return 1; fi
99
- printf '%s' "$out" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
149
+ printf '%s' "$out" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" --arg cls "$CLS" "$JQ_PRE"'
100
150
  select(.pull_request == null) | select(trusted) | select((.body // "") | contains($m))
151
+ | select((((.body // "") | capture("FORGE:DEFERRED_CLASS: (?<c>[a-z-]+)") | .c) // "engineering") == $cls)
101
152
  | "\(.number)\t\(.state)"' 2>/dev/null
102
153
  }
103
154
  CANON=""; CSTATE=""
@@ -121,15 +172,127 @@ find_canonical() {
121
172
  pick_verified $cands || return 1
122
173
  return 0
123
174
  }
124
- LOOKUP_OK=""
125
- for attempt in 1 2; do
126
- if find_canonical; then LOOKUP_OK=1; break; fi
127
- [ "$attempt" = 1 ] && sleep "${FORGE_DEFERRED_RETRY_SLEEP:-2}"
175
+ # --- #4724: the post-deploy rollup: ONE open issue per release lane, found by marker (not by parent) ------------------
176
+ RELEASE_LANE="${FORGE_RELEASE_LANE:-default}"
177
+ ROLLUP_MARKER="<!-- FORGE:POSTDEPLOY_ROLLUP: ${RELEASE_LANE} -->"
178
+ verify_rollup() { # number -> `number<TAB>open` when a trusted, open, non-PR issue carries the rollup marker
179
+ local out rc
180
+ out=$(gh api "repos/${REPO}/issues/$1" 2>"$TMP/verr"); rc=$?
181
+ if [ "$rc" -ne 0 ]; then grep -qE '404|Not Found|410' "$TMP/verr" 2>/dev/null && return 0; return 1; fi
182
+ printf '%s' "$out" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$ROLLUP_MARKER" "$JQ_PRE"'
183
+ select(.pull_request == null) | select(trusted) | select(.state == "open") | select((.body // "") | contains($m))
184
+ | "\(.number)\t\(.state)"' 2>/dev/null
185
+ }
186
+ find_rollup() { # sets CANON/CSTATE to the lowest open rollup of the lane; non-zero only when a lookup failed
187
+ local cands n r
188
+ CANON=""; CSTATE=""
189
+ cands=$(gh api -X GET search/issues -f per_page=50 -f q="\"FORGE:POSTDEPLOY_ROLLUP: ${RELEASE_LANE}\" in:body repo:${REPO} is:issue is:open" 2>/dev/null \
190
+ | jq -r '[.items[].number] | sort | .[]' 2>/dev/null) || return 1
191
+ for n in $cands; do
192
+ r=$(verify_rollup "$n") || return 1
193
+ if [ -n "$r" ]; then CANON="${r%%$'\t'*}"; CSTATE="${r#*$'\t'}"; return 0; fi
194
+ done
195
+ return 0
196
+ }
197
+ # The root parent: a parent that is itself a follow-up (or the rollup) files its items under the parent it came from.
198
+ ROOT="$PARENT"
199
+ resolve_root() {
200
+ local pb first
201
+ pb=$(gh api "repos/${REPO}/issues/${PARENT}" 2>/dev/null | jq -r '.body // ""' 2>/dev/null) || return 0
202
+ first=$(printf '%s' "$pb" | grep -oE 'FORGE:DEFERRED_FROM: #[0-9]+' | head -n 1 | grep -oE '[0-9]+$')
203
+ [ -n "$first" ] && ROOT="$first"
204
+ return 0
205
+ }
206
+ norm() { # normalized item text: no `R<n>:` prefix, lower case, punctuation and spacing collapsed (dedup across lanes)
207
+ printf '%s' "$1" | sed -E 's/^R[0-9]+:[[:space:]]*//' | tr 'A-Z' 'a-z' | tr -c 'a-z0-9\n' ' ' | tr -s ' ' | sed -E 's/^ //; s/ $//'
208
+ }
209
+ # Keep only the items of $1 whose normalized text is not yet in $2 (a body); the kept ones are appended to "$TMP/add".
210
+ rollup_missing() { # items-file body-file
211
+ local p w k fp cl n
212
+ : > "$TMP/add"; : > "$TMP/have"
213
+ grep -E '^- \[[ xX]\] `' "$2" 2>/dev/null | sed -E 's/^- \[[ xX]\] `([^`]*)`.*/\1/' | while IFS= read -r l; do norm "$l"; echo; done > "$TMP/have"
214
+ while IFS="$SEP" read -r p w k fp cl; do
215
+ n=$(norm "$p")
216
+ grep -qxF -- "$n" "$TMP/have" && continue
217
+ entry "$p" "$w" "$k" >> "$TMP/add"; printf '%s\n' "$n" >> "$TMP/have"
218
+ done < "$1"
219
+ }
220
+ rollup_append() { # number ; non-zero = failure
221
+ local num="$1"
222
+ gh api "repos/${REPO}/issues/${num}" --jq '.body // ""' > "$TMP/body" 2>/dev/null || return 1
223
+ rollup_missing "$ITEMS_F" "$TMP/body"
224
+ [ -s "$TMP/add" ] || return 0
225
+ awk -v h="### #${ROOT}" -v root="$ROOT" -v addf="$TMP/add" '
226
+ BEGIN { while ((getline l < addf) > 0) add = add l "\n" }
227
+ { if (insec && $0 ~ /^### /) { printf "%s\n", add; insec = 0; done = 1 } if ($0 == h) insec = 1; print }
228
+ END { if (insec) printf "%s", add; else if (!done) printf "\n%s\n<!-- FORGE:DEFERRED_FROM: #%s -->\n\n%s", h, root, add }' "$TMP/body" > "$TMP/newbody"
229
+ if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: gh issue edit $num (append $(wc -l < "$TMP/add" | tr -d ' ') item(s) under #${ROOT})" >&2
230
+ else gh issue edit "$num" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1 || return 1; fi
231
+ return 0
232
+ }
233
+ file_rollup() { # items in $ITEMS_F; sets FILED_N, FILED_NEW
234
+ local n_items; n_items=$(wc -l < "$ITEMS_F" | tr -d ' ')
235
+ FILED_N=""; FILED_NEW=0
236
+ resolve_root
237
+ CANON=$(cut -f1 "$TMP/canon.$CLS")
238
+ if [ -n "$CANON" ]; then
239
+ rollup_append "$CANON" || { echo "DEFERRED_FILE=FAILED"; exit 4; }
240
+ FILED_N="$CANON"; return 0
241
+ fi
242
+ : > "$TMP/body"; rollup_missing "$ITEMS_F" "$TMP/body"
243
+ {
244
+ printf '## Problem\n\nPost-deploy checklist of release lane `%s`%s. Every merged lane appends its post-deploy items under a `### #<parent>` heading (deduplicated), so one read-only verification works through this one issue per release.\n\n' "$RELEASE_LANE" "${FORGE_RELEASE_SHA:+ (deploy target at $FORGE_RELEASE_SHA)}"
245
+ printf '## Acceptance Criteria\n\n### #%s\n<!-- FORGE:DEFERRED_FROM: #%s -->\n\n' "$ROOT" "$ROOT"
246
+ cat "$TMP/add"
247
+ printf '\n%s\n<!-- FORGE:DEFERRED_CLASS: post-deploy -->\n' "$ROLLUP_MARKER"
248
+ } > "$TMP/newissue"
249
+ local NUM
250
+ if [ -n "${DRY_RUN:-}" ]; then
251
+ echo "DRY_RUN: gh issue create (post-deploy rollup ${RELEASE_LANE}: ${n_items} item(s) from #${PARENT})" >&2
252
+ NUM=0
253
+ else
254
+ resolve_milestone
255
+ local args=() URL
256
+ gh label create "followup:post-deploy" -R "$REPO" --force --color BFD4F2 --description "Deferred post-deploy checks. Managed by ForgeDock." >/dev/null 2>&1 || true
257
+ args=(--label "followup:post-deploy")
258
+ [ -n "$MS_TITLE" ] && args+=(--milestone "$MS_TITLE")
259
+ URL=$(gh issue create -R "$REPO" --title "post-deploy: release checklist (${RELEASE_LANE})" --body-file "$TMP/newissue" "${args[@]}" 2>/dev/null) || URL=""
260
+ NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
261
+ [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
262
+ # Post-create re-check: a concurrent lane may have filed first. The lowest open rollup is canonical; the newer one is closed.
263
+ if find_rollup; then
264
+ if [ -n "$CANON" ] && [ "$CANON" -lt "$NUM" ]; then
265
+ if rollup_append "$CANON"; then
266
+ gh issue close "$NUM" -R "$REPO" --reason "not planned" --comment "Duplicate of #${CANON}: the post-deploy items of lane ${RELEASE_LANE} are tracked there." >/dev/null 2>&1 \
267
+ || echo "file-deferred-followups: warning: could not close duplicate #${NUM}" >&2
268
+ NUM="$CANON"
269
+ else
270
+ echo "file-deferred-followups: warning: could not move items to #${CANON}; keeping #${NUM}" >&2
271
+ fi
272
+ fi
273
+ else
274
+ echo "file-deferred-followups: warning: post-create re-check failed; keeping #${NUM}" >&2
275
+ fi
276
+ fi
277
+ FILED_N="$NUM"; FILED_NEW=1
278
+ }
279
+
280
+ CLS_LIST=$(cut -d "$SEP" -f5 "$TMP/items" | sort -u)
281
+ CLS=""
282
+ # Phase 1: look up the canonical follow-up of every class before filing anything (a failed lookup files nothing).
283
+ for CLS in $CLS_LIST; do
284
+ LOOKUP_OK=""
285
+ for attempt in 1 2; do
286
+ if { [ "$CLS" = "post-deploy" ] && find_rollup; } || { [ "$CLS" != "post-deploy" ] && find_canonical; }; then LOOKUP_OK=1; break; fi
287
+ [ "$attempt" = 1 ] && sleep "${FORGE_DEFERRED_RETRY_SLEEP:-2}"
288
+ done
289
+ if [ -z "$LOOKUP_OK" ]; then
290
+ echo "DEFERRED_LOOKUP=FAILED"
291
+ exit 3
292
+ fi
293
+ printf '%s\t%s\n' "$CANON" "$CSTATE" > "$TMP/canon.$CLS"
294
+ awk -F"$SEP" -v c="$CLS" '$5 == c' "$TMP/items" > "$TMP/items.$CLS"
128
295
  done
129
- if [ -z "$LOOKUP_OK" ]; then
130
- echo "DEFERRED_LOOKUP=FAILED"
131
- exit 3
132
- fi
133
296
 
134
297
  # Checklist line for one item. Path always backticked so review-pr 6B.5 `contains` verification holds.
135
298
  entry() { # path why flag
@@ -149,9 +312,9 @@ append_items() { # number state ; non-zero = failure
149
312
  # Re-read the body immediately before editing (parallel builds of the same parent), then append what is missing.
150
313
  gh api "repos/${REPO}/issues/${num}" --jq '.body // ""' > "$TMP/body" 2>/dev/null || return 1
151
314
  : > "$TMP/add"
152
- while IFS="$SEP" read -r p w k; do
315
+ while IFS="$SEP" read -r p w k fp cl; do
153
316
  has_entry "$TMP/body" "$p" || entry "$p" "$w" "$k" >> "$TMP/add"
154
- done < "$TMP/items"
317
+ done < "$ITEMS_F"
155
318
  [ "$state" = "closed" ] && note=" (follow-up was closed; reopened)"
156
319
  if [ -s "$TMP/add" ]; then
157
320
  { cat "$TMP/body"; printf '\n### Added by a later build of #%s%s\n\n' "$PARENT" "$note"; cat "$TMP/add"; } > "$TMP/newbody"
@@ -168,54 +331,99 @@ append_items() { # number state ; non-zero = failure
168
331
  return 0
169
332
  }
170
333
 
171
- if [ -n "$CANON" ]; then
172
- append_items "$CANON" "$CSTATE" || { echo "DEFERRED_FILE=FAILED"; exit 4; }
173
- while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$CANON"; done < "$TMP/items"
174
- finish "$CANON" 0
175
- exit 0
176
- fi
177
-
178
- [ "$TOTAL" -gt 0 ] || { finish "" 0; exit 0; }
179
-
180
- # --- Create ONE issue with a checklist for every item ---------------------------------------------------------
181
- {
182
- printf '## Problem\n\n'
183
- printf 'Deferred from #%s: the items below were declared out of scope in its contract and are tracked here, in one issue, instead of surfacing later as review findings.\n\n' "$PARENT"
184
- printf '## Affected Files\n\n'
185
- while IFS="$SEP" read -r p w k; do printf -- '- `%s`\n' "$p"; done < "$TMP/items"
186
- printf '\n## Acceptance Criteria\n\n'
187
- while IFS="$SEP" read -r p w k; do entry "$p" "$w" "$k"; done < "$TMP/items"
188
- printf '\n%s\n' "$MARKER"
189
- } > "$TMP/newissue"
334
+ MS_DONE=""; MS_TITLE=""
335
+ resolve_milestone() { # sets MS_TITLE (empty = none): the parent's milestone, or for a shipped one the next open milestone, else the configured default
336
+ [ -n "$MS_DONE" ] && return 0
337
+ MS_DONE=1; MS_TITLE=""
338
+ local t="${FORGE_FOLLOWUP_MILESTONE:-}" all
339
+ if [ -z "$t" ]; then t=$(gh api "repos/${REPO}/issues/${PARENT}" --jq '.milestone.title // ""' 2>/dev/null) || t=""; fi
340
+ [ -n "$t" ] || return 0
341
+ all=$(gh api "repos/${REPO}/milestones?state=all&per_page=100" 2>/dev/null) || { MS_TITLE="$t"; return 0; }
342
+ MS_TITLE=$(printf '%s' "$all" | jq -r --arg t "$t" --arg d "${FORGE_FOLLOWUPS_DEFAULT_MILESTONE:-}" '
343
+ . as $all | ($all | map(select(.title == $t)) | first // null) as $cur
344
+ | if $cur == null or $cur.state == "open" then $t
345
+ else (($all | map(select(.state == "open" and .due_on != null and ($cur.due_on == null or .due_on > $cur.due_on))) | sort_by(.due_on) | (first // {}) | .title // "") as $next
346
+ | if $next != "" then $next elif ($d != "" and ($all | map(select(.title == $d and .state == "open")) | length) > 0) then $d else "" end) end' 2>/dev/null) || MS_TITLE="$t"
347
+ }
190
348
 
191
- if [ -n "${DRY_RUN:-}" ]; then
192
- echo "DRY_RUN: gh issue create (follow-up: ${TOTAL} deferred item(s) from #${PARENT})" >&2
193
- NUM=0
194
- else
195
- URL=$(gh issue create -R "$REPO" --title "follow-up: ${TOTAL} deferred item(s) (deferred from #${PARENT})" \
196
- --body-file "$TMP/newissue" 2>/dev/null) || URL=""
197
- NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
198
- [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
199
- fi
200
- if [ -z "${DRY_RUN:-}" ]; then
201
- # Claim the follow-up on the parent (REST comments are read-after-write consistent; best effort, the search still finds it).
202
- gh issue comment "$PARENT" -R "$REPO" --body "<!-- FORGE:DEFERRED_ISSUE: #${NUM} -->" >/dev/null 2>&1 \
203
- || echo "file-deferred-followups: warning: could not post claim marker for #${NUM} on #${PARENT}" >&2
204
- # Post-create re-check: a concurrent build may have filed first. The lowest trusted number is canonical; the newer one is closed.
205
- if find_canonical; then
206
- if [ -n "$CANON" ] && [ "$CANON" -lt "$NUM" ]; then
207
- if append_items "$CANON" "$CSTATE"; then
208
- gh issue close "$NUM" -R "$REPO" --reason "not planned" --comment "Duplicate of #${CANON}: deferred items of #${PARENT} are tracked there." >/dev/null 2>&1 \
209
- || echo "file-deferred-followups: warning: could not close duplicate #${NUM}" >&2
210
- NUM="$CANON"
211
- else
212
- echo "file-deferred-followups: warning: could not move items to #${CANON}; keeping #${NUM}" >&2
213
- fi
349
+ # File one class ($CLS, items in $ITEMS_F): append to its canonical follow-up, or create one. Sets FILED_N, FILED_NEW.
350
+ file_class() {
351
+ local n_items; n_items=$(wc -l < "$ITEMS_F" | tr -d ' ')
352
+ FILED_N=""; FILED_NEW=0
353
+ CANON=$(cut -f1 "$TMP/canon.$CLS"); CSTATE=$(cut -f2 "$TMP/canon.$CLS")
354
+ if [ -n "$CANON" ]; then
355
+ append_items "$CANON" "$CSTATE" || { echo "DEFERRED_FILE=FAILED"; exit 4; }
356
+ FILED_N="$CANON"; return 0
357
+ fi
358
+ local claim="<!-- FORGE:DEFERRED_ISSUE: #%s -->" title="follow-up: ${n_items} deferred item(s) (deferred from #${PARENT})"
359
+ if [ "$CLS" != "engineering" ]; then claim="<!-- FORGE:DEFERRED_ISSUE: #%s class=${CLS} -->"; title="follow-up (${CLS}): ${n_items} deferred item(s) (deferred from #${PARENT})"; fi
360
+ local paths; paths=$(awk -F"$SEP" '$4 != "-" { print $4 }' "$ITEMS_F" | awk '!seen[$0]++')
361
+ {
362
+ printf '## Problem\n\n'
363
+ printf 'Deferred from #%s: the items below were declared out of scope in its contract and are tracked here, in one issue per class (%s), instead of surfacing later as review findings.\n\n' "$PARENT" "$CLS"
364
+ if [ "$CLS" = "external" ]; then
365
+ printf 'External prerequisite of #%s: these items wait on something outside the repo (credentials, an account, a third party).' "$PARENT"
366
+ [ -n "${FORGE_EXTERNAL_PREREQ:-}" ] && printf ' Linked prerequisite: #%s.' "${FORGE_EXTERNAL_PREREQ#\#}"
367
+ printf '\n\n'
214
368
  fi
369
+ printf '## Affected Files\n\n'
370
+ if [ -n "$paths" ]; then printf '%s\n' "$paths" | while IFS= read -r fp; do printf -- '- `%s`\n' "$fp"; done
371
+ else printf -- '- None (requirement-level items, see the checklist)\n'; fi
372
+ printf '\n## Acceptance Criteria\n\n'
373
+ while IFS="$SEP" read -r p w k fp cl; do entry "$p" "$w" "$k"; done < "$ITEMS_F"
374
+ printf '\n%s\n<!-- FORGE:DEFERRED_CLASS: %s -->\n' "$MARKER" "$CLS"
375
+ } > "$TMP/newissue"
376
+ local NUM
377
+ if [ -n "${DRY_RUN:-}" ]; then
378
+ echo "DRY_RUN: gh issue create (follow-up ${CLS}: ${n_items} deferred item(s) from #${PARENT})" >&2
379
+ NUM=0
215
380
  else
216
- echo "file-deferred-followups: warning: post-create re-check failed; keeping #${NUM}" >&2
381
+ resolve_milestone
382
+ local lbl="followup:${CLS}" args=() URL
383
+ gh label create "$lbl" -R "$REPO" --force --color BFD4F2 --description "Deferred follow-up (${CLS}). Managed by ForgeDock." >/dev/null 2>&1 || true
384
+ args=(--label "$lbl")
385
+ if [ "$CLS" = "external" ]; then
386
+ gh label create external-prerequisite -R "$REPO" --force --color E99695 --description "External prerequisite. Managed by ForgeDock." >/dev/null 2>&1 || true
387
+ args+=(--label external-prerequisite)
388
+ fi
389
+ [ -n "$MS_TITLE" ] && args+=(--milestone "$MS_TITLE")
390
+ URL=$(gh issue create -R "$REPO" --title "$title" --body-file "$TMP/newissue" "${args[@]}" 2>/dev/null) || URL=""
391
+ NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
392
+ [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
393
+ fi
394
+ if [ -z "${DRY_RUN:-}" ]; then
395
+ # Claim the follow-up on the parent (REST comments are read-after-write consistent; best effort, the search still finds it).
396
+ # shellcheck disable=SC2059
397
+ gh issue comment "$PARENT" -R "$REPO" --body "$(printf "$claim" "$NUM")" >/dev/null 2>&1 \
398
+ || echo "file-deferred-followups: warning: could not post claim marker for #${NUM} on #${PARENT}" >&2
399
+ # Post-create re-check: a concurrent build may have filed first. The lowest trusted number is canonical; the newer one is closed.
400
+ if find_canonical; then
401
+ if [ -n "$CANON" ] && [ "$CANON" -lt "$NUM" ]; then
402
+ if append_items "$CANON" "$CSTATE"; then
403
+ gh issue close "$NUM" -R "$REPO" --reason "not planned" --comment "Duplicate of #${CANON}: deferred items of #${PARENT} are tracked there." >/dev/null 2>&1 \
404
+ || echo "file-deferred-followups: warning: could not close duplicate #${NUM}" >&2
405
+ NUM="$CANON"
406
+ else
407
+ echo "file-deferred-followups: warning: could not move items to #${CANON}; keeping #${NUM}" >&2
408
+ fi
409
+ fi
410
+ else
411
+ echo "file-deferred-followups: warning: post-create re-check failed; keeping #${NUM}" >&2
412
+ fi
217
413
  fi
218
- fi
219
- while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
220
- finish "$NUM" 1
414
+ FILED_N="$NUM"; FILED_NEW=1
415
+ }
416
+
417
+ [ "$TOTAL" -gt 0 ] || { finish "" 0; exit 0; }
418
+
419
+ # Phase 2: one issue per class.
420
+ INTO=""; FILED=0
421
+ for CLS in $CLS_LIST; do
422
+ ITEMS_F="$TMP/items.$CLS"
423
+ if [ "$CLS" = "post-deploy" ]; then file_rollup; else file_class; fi
424
+ case " $INTO " in *" $FILED_N "*) ;; *) INTO="${INTO:+$INTO }$FILED_N" ;; esac
425
+ FILED=$((FILED + FILED_NEW))
426
+ while IFS="$SEP" read -r p w k fp cl; do printf '%s\t%s\n' "$p" "$FILED_N"; done < "$ITEMS_F"
427
+ done
428
+ finish "$INTO" "$FILED"
221
429
  exit 0