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.
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- 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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
local into="none"
|
|
64
|
-
|
|
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="${
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 < "$
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
fi
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
fi
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if [
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|