forgedock 1.35.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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -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,13 +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).
26
+ # A post-deploy why starts `post-deploy: [check:<id>] ...` (#4626); the token is kept verbatim in the checklist line.
13
27
  # Env: DRY_RUN (non-empty: no create/edit, issue number 0), FORGE_TRAIL_TRUSTED_ASSOCIATIONS (default
14
- # OWNER,MEMBER,COLLABORATOR), FORGE_TRAIL_TRUSTED_LOGINS (default empty), FORGE_DEFERRED_RETRY_SLEEP (secs).
15
- # Stdout: `path<TAB>issue` per item, then DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M filed=N.
16
- # 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).
17
33
  # A failed lookup is never "none found". bash 3.2 portable.
18
34
 
19
35
  set -uo pipefail
@@ -37,37 +53,72 @@ TMP=$(mktemp -d "${TMPDIR:-/tmp}/deferred-followups.XXXXXX") || exit 2
37
53
  trap 'rm -rf "$TMP"' EXIT
38
54
  if [ "$ITEMS" = "-" ]; then cat > "$TMP/items.raw"; else cat "$ITEMS" > "$TMP/items.raw"; fi
39
55
 
40
- # 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.
41
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.
42
59
  SEP=$(printf '\037')
60
+ CLASSES="engineering post-deploy external ops verify-now"
43
61
  : > "$TMP/items"
62
+ BAD=0; ROW=0
63
+ bad_row() { BAD=$((BAD+1)); echo "file-deferred-followups: row $ROW: $1" >&2; }
44
64
  while IFS= read -r line || [ -n "$line" ]; do
45
- # Split on TAB by hand: `IFS=$'\t' read` would collapse an empty why field and shift the flag into it.
46
- p="${line%%$'\t'*}"; rest=""; w=""; k=""
47
- case "$line" in *$'\t'*) rest="${line#*$'\t'}"; w="${rest%%$'\t'*}"; case "$rest" in *$'\t'*) k="${rest#*$'\t'}" ;; esac ;; esac
48
- p=$(printf '%s' "${p:-}" | tr -d '`' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
49
- [ -n "$p" ] || continue
50
- 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
51
99
  case "${k:-}" in known-flaw|1|yes|true) k="known-flaw" ;; *) k="" ;; esac
52
- w=$(printf '%s' "${w:-}" | tr '\r\n' ' ')
53
- 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"
54
101
  done < "$TMP/items.raw"
102
+ if [ "$BAD" -gt 0 ]; then echo "DEFERRED_TSV=INVALID"; exit 2; fi
55
103
  TOTAL=$(wc -l < "$TMP/items" | tr -d ' ')
56
104
 
57
105
  MARKER="<!-- FORGE:DEFERRED_FROM: #${PARENT} -->"
58
106
  TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
59
107
  TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
60
108
 
61
- finish() { # $1 = batched_into number or empty, $2 = filed count
62
- local into="none"; [ -n "$1" ] && into="#$1"
63
- 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}"
64
115
  echo "$line"
65
116
  # #3834 emitter (scripts/lib/telemetry.sh): optional, never blocks, never changes stdout or the exit code.
66
117
  local d; d=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
67
118
  if [ -f "$d/lib/telemetry.sh" ]; then
68
119
  FORGE_ISSUE="${FORGE_ISSUE:-$PARENT}" FORGE_REPO="${FORGE_REPO:-$REPO}" \
69
120
  bash "$d/lib/telemetry.sh" deferred_followups deferred_items="$TOTAL" folded="$FOLDED" \
70
- 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
71
122
  fi
72
123
  }
73
124
 
@@ -79,7 +130,7 @@ JQ_PRE='def lst: split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length >
79
130
  def trusted: ((.author_association // "") as $x | ($assoc | lst) | index($x) != null)
80
131
  or ((.user.type // "") == "Bot")
81
132
  or ((.user.login // "") as $x | ($logins | lst) | index($x) != null);'
82
- CLAIM_RE='^\s*<!-- FORGE:DEFERRED_ISSUE: #(?<n>[0-9]+) -->'
133
+ CLAIM_RE='^\s*<!-- FORGE:DEFERRED_ISSUE: #(?<n>[0-9]+)(?: class=[a-z-]+)? -->'
83
134
  claim_nums() {
84
135
  gh api --paginate "repos/${REPO}/issues/${PARENT}/comments" 2>/dev/null \
85
136
  | jq -s -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg re "$CLAIM_RE" "$JQ_PRE"'
@@ -91,12 +142,13 @@ search_nums() {
91
142
  | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
92
143
  [.items[] | select(trusted) | select((.body // "") | contains($m)) | .number] | sort | .[]' 2>/dev/null
93
144
  }
94
- 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")
95
146
  local out rc
96
147
  out=$(gh api "repos/${REPO}/issues/$1" 2>"$TMP/verr"); rc=$?
97
148
  if [ "$rc" -ne 0 ]; then grep -qE '404|Not Found|410' "$TMP/verr" 2>/dev/null && return 0; return 1; fi
98
- 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"'
99
150
  select(.pull_request == null) | select(trusted) | select((.body // "") | contains($m))
151
+ | select((((.body // "") | capture("FORGE:DEFERRED_CLASS: (?<c>[a-z-]+)") | .c) // "engineering") == $cls)
100
152
  | "\(.number)\t\(.state)"' 2>/dev/null
101
153
  }
102
154
  CANON=""; CSTATE=""
@@ -120,15 +172,127 @@ find_canonical() {
120
172
  pick_verified $cands || return 1
121
173
  return 0
122
174
  }
123
- LOOKUP_OK=""
124
- for attempt in 1 2; do
125
- if find_canonical; then LOOKUP_OK=1; break; fi
126
- [ "$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"
127
295
  done
128
- if [ -z "$LOOKUP_OK" ]; then
129
- echo "DEFERRED_LOOKUP=FAILED"
130
- exit 3
131
- fi
132
296
 
133
297
  # Checklist line for one item. Path always backticked so review-pr 6B.5 `contains` verification holds.
134
298
  entry() { # path why flag
@@ -148,9 +312,9 @@ append_items() { # number state ; non-zero = failure
148
312
  # Re-read the body immediately before editing (parallel builds of the same parent), then append what is missing.
149
313
  gh api "repos/${REPO}/issues/${num}" --jq '.body // ""' > "$TMP/body" 2>/dev/null || return 1
150
314
  : > "$TMP/add"
151
- while IFS="$SEP" read -r p w k; do
315
+ while IFS="$SEP" read -r p w k fp cl; do
152
316
  has_entry "$TMP/body" "$p" || entry "$p" "$w" "$k" >> "$TMP/add"
153
- done < "$TMP/items"
317
+ done < "$ITEMS_F"
154
318
  [ "$state" = "closed" ] && note=" (follow-up was closed; reopened)"
155
319
  if [ -s "$TMP/add" ]; then
156
320
  { cat "$TMP/body"; printf '\n### Added by a later build of #%s%s\n\n' "$PARENT" "$note"; cat "$TMP/add"; } > "$TMP/newbody"
@@ -167,54 +331,99 @@ append_items() { # number state ; non-zero = failure
167
331
  return 0
168
332
  }
169
333
 
170
- if [ -n "$CANON" ]; then
171
- append_items "$CANON" "$CSTATE" || { echo "DEFERRED_FILE=FAILED"; exit 4; }
172
- while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$CANON"; done < "$TMP/items"
173
- finish "$CANON" 0
174
- exit 0
175
- fi
176
-
177
- [ "$TOTAL" -gt 0 ] || { finish "" 0; exit 0; }
178
-
179
- # --- Create ONE issue with a checklist for every item ---------------------------------------------------------
180
- {
181
- printf '## Problem\n\n'
182
- 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"
183
- printf '## Affected Files\n\n'
184
- while IFS="$SEP" read -r p w k; do printf -- '- `%s`\n' "$p"; done < "$TMP/items"
185
- printf '\n## Acceptance Criteria\n\n'
186
- while IFS="$SEP" read -r p w k; do entry "$p" "$w" "$k"; done < "$TMP/items"
187
- printf '\n%s\n' "$MARKER"
188
- } > "$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
+ }
189
348
 
190
- if [ -n "${DRY_RUN:-}" ]; then
191
- echo "DRY_RUN: gh issue create (follow-up: ${TOTAL} deferred item(s) from #${PARENT})" >&2
192
- NUM=0
193
- else
194
- URL=$(gh issue create -R "$REPO" --title "follow-up: ${TOTAL} deferred item(s) (deferred from #${PARENT})" \
195
- --body-file "$TMP/newissue" 2>/dev/null) || URL=""
196
- NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
197
- [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
198
- fi
199
- if [ -z "${DRY_RUN:-}" ]; then
200
- # Claim the follow-up on the parent (REST comments are read-after-write consistent; best effort, the search still finds it).
201
- gh issue comment "$PARENT" -R "$REPO" --body "<!-- FORGE:DEFERRED_ISSUE: #${NUM} -->" >/dev/null 2>&1 \
202
- || echo "file-deferred-followups: warning: could not post claim marker for #${NUM} on #${PARENT}" >&2
203
- # Post-create re-check: a concurrent build may have filed first. The lowest trusted number is canonical; the newer one is closed.
204
- if find_canonical; then
205
- if [ -n "$CANON" ] && [ "$CANON" -lt "$NUM" ]; then
206
- if append_items "$CANON" "$CSTATE"; then
207
- gh issue close "$NUM" -R "$REPO" --reason "not planned" --comment "Duplicate of #${CANON}: deferred items of #${PARENT} are tracked there." >/dev/null 2>&1 \
208
- || echo "file-deferred-followups: warning: could not close duplicate #${NUM}" >&2
209
- NUM="$CANON"
210
- else
211
- echo "file-deferred-followups: warning: could not move items to #${CANON}; keeping #${NUM}" >&2
212
- 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'
213
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
214
380
  else
215
- 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
216
413
  fi
217
- fi
218
- while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
219
- 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"
220
429
  exit 0
@@ -16,7 +16,8 @@ echo "$*" >> "$GH_LOG"
16
16
  case "$1 $2" in
17
17
  "api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
18
18
  "api --paginate") [ -n "${CLAIM_FAIL:-}" ] && exit 1; cat "$COMMENTS_JSON"; exit 0 ;;
19
- "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues/901"; exit 0 ;;
19
+ "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; N=$(( $(cat "$T_DIR/create.n" 2>/dev/null || echo 900) + 1 )); echo "$N" > "$T_DIR/create.n"; while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$T_DIR/created.$N.body"; shift; done; echo "https://github.com/o/r/issues/$N"; exit 0 ;;
20
+ "label create") exit 0 ;;
20
21
  "issue comment") [ -n "${COMMENT_FAIL:-}" ] && exit 1; exit 0 ;;
21
22
  "issue reopen") [ -n "${REOPEN_FAIL:-}" ] && exit 1; exit 0 ;;
22
23
  "issue close") [ -n "${CLOSE_FAIL:-}" ] && exit 1; exit 0 ;;
@@ -25,6 +26,8 @@ case "$1 $2" in
25
26
  while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
26
27
  esac
27
28
  if [ "$1" = api ]; then
29
+ case "$*" in "api repos/o/r/milestones"*) cat "$T_DIR/milestones.json"; exit 0 ;; esac
30
+ case "$*" in "api repos/o/r/issues/10 --jq"*) cat "$T_DIR/parent.milestone"; exit 0 ;; esac
28
31
  case "$*" in *--jq*) cat "$ISSUE_BODY"; exit 0 ;; esac
29
32
  n="${2##*/}"; [ -f "$T_DIR/issue.$n.json" ] && { cat "$T_DIR/issue.$n.json"; exit 0; }
30
33
  echo "HTTP 404: Not Found" >&2; exit 1
@@ -37,7 +40,8 @@ PASS=0; FAIL=0
37
40
  ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
38
41
  bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
39
42
  check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
40
- reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN
43
+ reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json "$T/create.n"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN FORGE_FOLLOWUP_MILESTONE FORGE_FOLLOWUPS_DEFAULT_MILESTONE FORGE_EXTERNAL_PREREQ
44
+ echo '[]' > "$T/milestones.json"; : > "$T/parent.milestone"
41
45
  echo '{"items":[]}' > "$SEARCH_JSON"; echo '[]' > "$COMMENTS_JSON"; }
42
46
  # issue_json NUMBER STATE ASSOCIATION [PARENT]: REST view of a follow-up carrying the DEFERRED_FROM marker
43
47
  issue_json() { printf '{"number":%s,"state":"%s","author_association":"%s","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #%s -->"}\n' "$1" "$2" "$3" "${4:-10}" > "$T/issue.$1.json"; }
@@ -195,5 +199,116 @@ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER; claim_json OWNER 77
195
199
  bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
196
200
  check "dry-run: no comment/reopen/close/edit" "! grep -qE '^issue (comment|reopen|close|edit|create)' '$GH_LOG'"
197
201
 
202
+ # 18. #4626: a post-deploy why's [check:<id>] token is kept verbatim in the checklist line
203
+ reset
204
+ printf 'x\n' > "$ISSUE_BODY"
205
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
206
+ printf '{"number":77,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\\n<!-- FORGE:DEFERRED_FROM: #10 -->\\n### #10\\n<!-- FORGE:DEFERRED_CLASS: post-deploy -->"}\n' > "$T/issue.77.json"
207
+ printf -- '-\tpost-deploy: [check:login-smoke] after a day\t\t\tR3: soak\n' > "$T/check.tsv"
208
+ bash "$S" --parent 10 --repo o/r --items "$T/check.tsv" >/dev/null 2>&1
209
+ check "check token kept in the checklist line" "grep -q 'post-deploy: \[check:login-smoke\] after a day' '$ISSUE_BODY.edited'"
210
+
211
+ # 19. #4664: milestone inheritance
212
+ MS='[{"title":"M1","state":"open","due_on":"2026-01-01T00:00:00Z"},{"title":"M0","state":"closed","due_on":"2025-06-01T00:00:00Z"},{"title":"M2","state":"open","due_on":"2026-06-01T00:00:00Z"},{"title":"Dflt","state":"open","due_on":null}]'
213
+ reset; echo "$MS" > "$T/milestones.json"; export FORGE_FOLLOWUP_MILESTONE=M1
214
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
215
+ check "milestone: open parent milestone inherited" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
216
+ reset; echo "$MS" > "$T/milestones.json"; echo M1 > "$T/parent.milestone"
217
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
218
+ check "milestone: read from the parent when not passed" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
219
+ reset; echo "$MS" > "$T/milestones.json"; export FORGE_FOLLOWUP_MILESTONE=M0
220
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
221
+ check "milestone: shipped (closed) one -> next open by due date" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
222
+ reset; echo '[{"title":"M0","state":"closed","due_on":"2025-06-01T00:00:00Z"},{"title":"Dflt","state":"open","due_on":null}]' > "$T/milestones.json"
223
+ export FORGE_FOLLOWUP_MILESTONE=M0 FORGE_FOLLOWUPS_DEFAULT_MILESTONE=Dflt
224
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
225
+ check "milestone: shipped, no next -> followups.default_milestone" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone Dflt'"
226
+ reset; echo '[]' > "$T/milestones.json"
227
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
228
+ check "milestone: parent without one -> none passed" "grep -q '^issue create' '$GH_LOG' && ! grep '^issue create' '$GH_LOG' | grep -q -- '--milestone'"
229
+
230
+ # 20. #4664: one issue per class, class labels
231
+ printf 'a/x.sh\twhy a\t\nb/y.sh\tpost-deploy: [check:smoke] later\t\t\nc/z.sh\texternal: needs a real credential\t\t\nd/w.sh\tops drill\t\tops\n-\tdefer: plain\t\t\tR9: plain item\n' > "$T/classes.tsv"
232
+ reset
233
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/classes.tsv"); RC=$?
234
+ check "classes: rc 0, one create per class (4)" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 4 ]"
235
+ for c in engineering post-deploy external ops; do check "classes: label followup:$c applied" "grep '^issue create' '$GH_LOG' | grep -q -- '--label followup:$c'"; done
236
+ check "classes: external also labelled external-prerequisite" "grep '^issue create' '$GH_LOG' | grep -- 'followup:external' | grep -q -- '--label external-prerequisite'"
237
+ check "classes: engineering has the old title, others carry the class" "grep '^issue create' '$GH_LOG' | grep -q 'follow-up (ops): 1 deferred' && grep '^issue create' '$GH_LOG' | grep -q 'follow-up: 2 deferred'"
238
+ check "classes: telemetry lists every issue and class" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#901,#902,#903,#904 filed=4 classes=engineering,external,ops,post-deploy'"
239
+ check "classes: rows point at their class issue (a/x.sh and the R9 item share one)" "printf '%s\n' \"\$OUT\" | grep -q '^c/z.sh 90[0-9]\$' && [ \$(printf '%s\n' \"\$OUT\" | grep -E '^(a/x.sh|R9: plain item) ' | cut -f2 | sort -u | wc -l) -eq 1 ]"
240
+ check "classes: claim markers carry the class" "grep '^issue comment 10' '$GH_LOG' | grep -q 'class=external'"
241
+ # an existing engineering follow-up is not reused for another class
242
+ reset
243
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 open OWNER; claim_json OWNER 77
244
+ printf 'c/z.sh\texternal: needs a credential\t\n' > "$T/ext.tsv"
245
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ext.tsv"); RC=$?
246
+ check "classes: engineering follow-up #77 not reused for an external item" "[ $RC -eq 0 ] && grep -q '^issue create' '$GH_LOG' && ! grep -q '^issue edit 77' '$GH_LOG'"
247
+ # a labelled-class follow-up is reused for its class
248
+ reset
249
+ printf 'x\n' > "$ISSUE_BODY"
250
+ printf '{"number":78,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->\\n<!-- FORGE:DEFERRED_CLASS: external -->"}\n' > "$T/issue.78.json"; claim_json OWNER 78
251
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ext.tsv"); RC=$?
252
+ check "classes: external follow-up #78 reused for external items" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && grep -q '^issue edit 78' '$GH_LOG'"
253
+
254
+ # 21. #4664: requirement-text rows are rejected, nothing filed
255
+ for row in 'R1: a saved mapping per entity\twhy\t' 'R1: R2: a saved mapping\twhy\t' 'add a saved mapping\twhy\t' 'a/x.sh\twhy\t\tbogus'; do
256
+ reset; printf "$row\n" > "$T/bad.tsv"
257
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/bad.tsv" 2>/dev/null); RC=$?
258
+ check "rejected row ($(printf "$row" | cut -c1-24)): rc 2, DEFERRED_TSV=INVALID, no gh call" "[ $RC -eq 2 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_TSV=INVALID && [ ! -s '$GH_LOG' ]"
259
+ done
260
+ reset; printf 'a/x.sh\twhy\t\nR2: nope\twhy\t\n' > "$T/bad.tsv"
261
+ bash "$S" --parent 10 --repo o/r --items "$T/bad.tsv" >/dev/null 2>&1
262
+ check "one malformed row among valid ones files nothing" "[ $? -eq 2 ] && ! grep -q '^issue create' '$GH_LOG'"
263
+ # `-` + label is a valid requirement-level row; a doubled R-prefix in the label collapses; Affected Files lists only paths
264
+ reset; printf '%s\n' "$(printf -- '-\twhy\t\t\tR1: R2: a saved mapping')" "$(printf 'src/q.ts\twhy q\t')" > "$T/ok.tsv"
265
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ok.tsv"); RC=$?
266
+ check "label row accepted, prefix collapsed in stdout" "[ $RC -eq 0 ] && printf '%s\n' \"\$OUT\" | grep -q '^R1: a saved mapping 901'"
267
+
268
+ check "created body: Affected Files lists only paths, checklist keeps the collapsed label" "grep -q '^- \`src/q.ts\`\$' '$T/created.901.body' && ! grep -q '^- \`R1' '$T/created.901.body' && grep -q '^- \[ \] \`R1: a saved mapping\`' '$T/created.901.body' && ! grep -q 'R1: R2:' '$T/created.901.body'"
269
+ check "created body: DEFERRED_FROM and DEFERRED_CLASS markers" "grep -q 'FORGE:DEFERRED_FROM: #10 -->' '$T/created.901.body' && grep -q 'FORGE:DEFERRED_CLASS: engineering' '$T/created.901.body'"
270
+ # #4739: legal path characters (parentheses, space, =, #, :) are accepted; one such row no longer aborts the batch
271
+ reset; printf 'app/(marketing)/page.tsx\twhy\t\ndocs/My Notes.md\twhy\t\nsrc/a=b.ts\twhy\t\nsrc/#x.ts\twhy\t\nsrc/foo:bar.ts\twhy\t\n' > "$T/legal.tsv"
272
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/legal.tsv" 2>/dev/null); RC=$?
273
+ check "legal paths with ( ) space = # : are accepted and filed" "[ $RC -eq 0 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TSV=INVALID && grep -q '^issue create' '$GH_LOG' && grep -qF -- '- \`app/(marketing)/page.tsx\`' '$T/created.901.body' && grep -qF -- '- \`docs/My Notes.md\`' '$T/created.901.body' && grep -qF -- '- \`src/a=b.ts\`' '$T/created.901.body' && grep -qF -- '- \`src/#x.ts\`' '$T/created.901.body' && grep -qF -- '- \`src/foo:bar.ts\`' '$T/created.901.body'"
274
+ reset; printf -- '-\tpost-deploy: [check:login-smoke] soon\t\t\tR3: soak\n' > "$T/pd.tsv"
275
+ bash "$S" --parent 10 --repo o/r --items "$T/pd.tsv" >/dev/null 2>&1
276
+ check "post-deploy rollup: created with the lane marker, [check:] kept, section for the parent" "grep -q 'FORGE:POSTDEPLOY_ROLLUP: default' '$T/created.901.body' && grep -q 'post-deploy: \[check:login-smoke\] soon' '$T/created.901.body' && grep -q '^### #10\$' '$T/created.901.body'"
277
+
278
+ # 23. #4724: N lanes -> ONE rollup; sections per parent; duplicates stored once; owner items never join it
279
+ reset
280
+ printf -- '-\tpost-deploy: [bucket:post-deploy] [check:a-smoke] first\t\t\tR1: the login page renders\n-\towner: [bucket:owner] rotate the key. why\t\t\tR2: rotate the credential\n' > "$T/l1.tsv"
281
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/l1.tsv"); RC=$?
282
+ check "rollup: lane 1 creates one external follow-up (#901) and one rollup (#902)" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 2 ] && grep -q 'FORGE:POSTDEPLOY_ROLLUP: default' '$T/created.902.body' && grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body'"
283
+ check "rollup: the owner item is not in the rollup" "! grep -q 'rotate the credential' '$T/created.902.body' && grep -q 'rotate the credential' '$T/created.901.body'"
284
+ check "rollup: telemetry has per-bucket counts" "printf '%s' \"\$OUT\" | grep -q 'buckets=owner:1,post-deploy:1'"
285
+ cp "$T/created.902.body" "$ISSUE_BODY"; : > "$GH_LOG"
286
+ echo '{"items":[{"number":902}]}' > "$SEARCH_JSON"
287
+ printf '{"number":902,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->"}\n' > "$T/issue.902.json"
288
+ printf -- '-\tpost-deploy: [bucket:post-deploy] [check:a-smoke] again\t\t\tR7: The Login page renders!\n-\tpost-deploy: [bucket:date] due:2026-12-01 from:2026-10-27 later\t\t\tR8: soak for a week\n' > "$T/l2.tsv"
289
+ OUT=$(bash "$S" --parent 11 --repo o/r --items "$T/l2.tsv"); RC=$?
290
+ check "rollup: lane 2 appends to #902, creates nothing" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && grep -q '^issue edit 902' '$GH_LOG'"
291
+ check "rollup: own section for #11, soak item added" "grep -q '^### #11\$' '$ISSUE_BODY.edited' && grep -q 'R8: soak for a week' '$ISSUE_BODY.edited' && grep -q 'FORGE:DEFERRED_FROM: #11' '$ISSUE_BODY.edited'"
292
+ check "rollup: duplicate text from another lane stored once" "[ \$(grep -ci 'login page' '$ISSUE_BODY.edited') -eq 1 ] && printf '%s\n' \"\$OUT\" | grep -q '^R7: The Login page renders!.902'"
293
+ # a chain: parent 20 is itself a follow-up of #10 -> the items go under the root #10, no new issue
294
+ reset
295
+ cp "$T/created.902.body" "$ISSUE_BODY" 2>/dev/null || true
296
+ echo '{"items":[{"number":901}]}' > "$SEARCH_JSON"
297
+ printf '{"number":901,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->"}\n' > "$T/issue.901.json"
298
+ printf '{"number":20,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}\n' > "$T/issue.20.json"
299
+ printf -- '-\tpost-deploy: [bucket:post-deploy] [check:b-smoke] chain\t\t\tR9: a chained check\n' > "$T/l3.tsv"
300
+ bash "$S" --parent 20 --repo o/r --items "$T/l3.tsv" >/dev/null 2>&1
301
+ check "rollup: a follow-up of a follow-up appends under its root parent, no new issue" "! grep -q '^issue create' '$GH_LOG' && grep -q 'a chained check' '$ISSUE_BODY.edited' && ! grep -q '^### #20' '$ISSUE_BODY.edited'"
302
+ # verify-now: its own per-parent follow-up, never the rollup
303
+ reset
304
+ printf -- '-\tpost-deploy: [bucket:verify-now] on default already\t\t\tR1: check it now\n' > "$T/vn.tsv"
305
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/vn.tsv")
306
+ check "verify-now: separate per-parent issue, class verify-now, no rollup marker" "grep -q 'FORGE:DEFERRED_CLASS: verify-now' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body' && printf '%s' \"\$OUT\" | grep -q 'buckets=verify-now:1'"
307
+ # an owner bucket on a post-deploy class row is rerouted to external
308
+ reset
309
+ printf -- 'x/y.sh\towner: [bucket:owner] spend\t\tpost-deploy\n' > "$T/ow.tsv"
310
+ bash "$S" --parent 10 --repo o/r --items "$T/ow.tsv" >/dev/null 2>&1
311
+ check "owner bucket never lands in post-deploy, even when the row says so" "grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body'"
312
+
198
313
  echo "passed=$PASS failed=$FAIL"
199
314
  [ "$FAIL" -eq 0 ]