forgedock 1.16.2 → 1.18.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/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,1427 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-finalize.sh — deterministic review-pr Phases 6-9: finding triage and filing preparation (part A), verdict, calibration, merge gates, integrity (part B).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# One self-contained shell per subcommand: each re-derives every value it uses (no helper or variable is defined in one place and
|
|
7
|
+
# consumed in another), resolves ForgeDock's own helpers through forge_resolve (FORGEDOCK_HOME / plugin root / FORGE_ROOT / plugin
|
|
8
|
+
# cache; the working directory and the PR checkout are NEVER a script source), and treats everything reviewer-controlled (comment
|
|
9
|
+
# bodies, finding titles, file paths) as data: comment text is only ever read through scripts/trusted-comments.sh, JSON is built with
|
|
10
|
+
# `jq --arg`, nothing from a comment is eval'd or expanded in arithmetic.
|
|
11
|
+
#
|
|
12
|
+
# Usage: review-finalize.sh <subcommand> --pr N --repo owner/name --context FILE [options]
|
|
13
|
+
# extract 6A trusted-comment extraction + 6B dedup + 6B.4 provenance/concreteness gates. Writes the findings JSON.
|
|
14
|
+
# classify 6B.5 note disposition: classifier (classify-finding.sh, called as is), contract scope, cross-head re-flag, per-review
|
|
15
|
+
# cap, pre-existing route; posts FORGE:NOTE_DISPOSITION (and FORGE:INPR_FIX on the first auto-merge round).
|
|
16
|
+
# file-prepare 6C pre-filing work: labels, milestone, title and line-range dedup, regression to P1, pattern recurrence,
|
|
17
|
+
# severity-to-priority, BATCHABLE. Writes the JSON work list the caller files from.
|
|
18
|
+
# file-verify 6C post-filing parse: reads the issue-creation result text on stdin, prints ISSUE_NUM=N, exits 1 when no number.
|
|
19
|
+
# verdict 7A gate log + 7B base-conflict computation; posts the verdict comment when --result stale|clean|blocking is given.
|
|
20
|
+
# calibrate 7B.5 calibration-table consultation (can only tighten); records FORGE:CALIBRATION_CHECK on the linked issue.
|
|
21
|
+
# merge-gate Phase 8: note-disposition, in-PR fix, --issue, phase-trail, guard-state, base-conflict / calibration / trust routing,
|
|
22
|
+
# pre-merge mergeability, previously-escalated parking, CI gate, reviewed-head guard, merge. Never skips a gate.
|
|
23
|
+
# demilestone 8B / 9A: clears the milestone from open review-finding issues of a merged milestone PR.
|
|
24
|
+
# integrity Phase 9: stale-head check and the verifiable trusted REVIEW-AGENT count (carry-aware).
|
|
25
|
+
#
|
|
26
|
+
# Boundary: the filing call itself (the /issue skill with its programmatic contract) and the LLM-authored finding body stay in the
|
|
27
|
+
# spec: a shell script cannot invoke a skill. The script ends at the work list (file-prepare) and resumes at file-verify.
|
|
28
|
+
# Also left to the caller: INTEG-N findings from Phase 2.5, unstructured-finding extraction when `extract` reports 0 structured
|
|
29
|
+
# findings, pattern-class consolidation issue creation, project-board add, 6D, and the choice of which NOTEs are fixed or dropped
|
|
30
|
+
# (pass --notes-fixed / --notes-dropped; every other plain NOTE is counted as listed).
|
|
31
|
+
#
|
|
32
|
+
# Options:
|
|
33
|
+
# --pr N PR number (numeric, required) --repo owner/name (required)
|
|
34
|
+
# --context FILE the review-prepare.sh context JSON (schema_version 1 required): pr.review_sha, pr.base_ref, delta.mode/from,
|
|
35
|
+
# contract.issue (the PR's linked issue) are read from it, never re-derived
|
|
36
|
+
# --out-dir DIR artifacts (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-finalize-<pr>)
|
|
37
|
+
# --findings FILE input findings JSON (default: extract -> n/a, classify -> OUT_DIR/findings.json, file-prepare -> OUT_DIR/classified.json)
|
|
38
|
+
# --out FILE output JSON (default: extract -> OUT_DIR/findings.json, classify -> OUT_DIR/classified.json, file-prepare -> OUT_DIR/worklist.json)
|
|
39
|
+
# --auto-merge B classify: true enables the in-PR fix gate (auto-merge review with a linked issue, first round only)
|
|
40
|
+
# --notes-fixed N / --notes-dropped N classify: NOTE dispositions chosen by the caller (default 0); the rest are notes_listed
|
|
41
|
+
# --patterns FILE file-prepare: JSON object {finding id: FORGE:PATTERN slug}; absent = the recurrence check is skipped
|
|
42
|
+
# --dry-run classify: post no PR or issue comments; file-prepare: create no labels
|
|
43
|
+
# --scope-files FILE classify (staging bundle review): newline list of the files in review scope (cross-PR, unreviewed and deploy-wiring files). When
|
|
44
|
+
# given, a MEDIUM/LOW/INFO finding whose cited file is outside it becomes `NOTE out_of_scope` (never HIGH/CRITICAL, never a safety-exempt
|
|
45
|
+
# or re-flagged finding, never a finding with no file), counted as out_of_scope in the disposition record (`scope=scoped`).
|
|
46
|
+
# --prior-prs "N N" classify (staging bundle review; an empty list is valid): read the prior findings and NOTE lists of these PRs (the bundle
|
|
47
|
+
# members) for the cross-PR re-flag instead of this PR's own earlier heads. It also marks the run as a bundle review: the context's
|
|
48
|
+
# contract.issue is ignored (no single fixing issue, lineage none) and the disposition record carries out_of_scope= and scope=
|
|
49
|
+
# Phase 7-9 subcommands: --context FILE is optional; without it pass --review-sha SHA, --review-mode FULL|DELTA|BASE_SYNC_ONLY, --issue N, --base B.
|
|
50
|
+
# verdict: --merge-health S --merge-health-state S [--base B] [--head-branch H] [--is-milestone true|false] [--has-purpose-regression true|false]
|
|
51
|
+
# [--purpose-findings TEXT] [--result stale|clean|blocking] [--agents TEXT] [--findings-count N] [--blocking-count N]
|
|
52
|
+
# [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
|
|
53
|
+
# calibrate: [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--trust-needs-human B] [--dry-run]; the issue is context contract.issue or --issue N
|
|
54
|
+
# merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
|
|
55
|
+
# --base-conflict B --other-blocking B [--calibration-note T] [--table-cell-key K] [--base B] [--issue N]
|
|
56
|
+
# (every B is true|false; an empty or other value aborts the merge: the guard state must be bound)
|
|
57
|
+
# demilestone: --is-milestone true|false [--merge-state S] (8B passes MERGED; 9A omits it and the state is read from the PR)
|
|
58
|
+
# Env: FORGE_CONFIG (forge.yaml for review.max_filed_findings; default ./forge.yaml).
|
|
59
|
+
# Exit: 0 ok | 3 BLOCKED (REVIEW_RESULT: status: BLOCKED printed; the caller must stop the review, never skip) | 4 merge-gate only:
|
|
60
|
+
# REVIEW_RESULT: status: PHASE_TRAIL_FAILED printed, followed by the verifier's MISSING lines (re-dispatch is owned by work-on/review.md R4)
|
|
61
|
+
# | 1 file-verify found no issue number | 2 usage error
|
|
62
|
+
# merge-gate reports every non-merging routed outcome as MERGE_OUTCOME=<merged|merge-failed|parked-awaiting-merge|base-conflict|needs-human|
|
|
63
|
+
# protection-blocked|stale-review|ci-not-green>; everything except merged / merge-failed / parked-awaiting-merge also exits 3 with a blocker.
|
|
64
|
+
#
|
|
65
|
+
# Stdout of each subcommand is KEY=value lines (plus REVIEW_RESULT on BLOCKED). Bash 3.2 portable (no mapfile, associative arrays, grep -P).
|
|
66
|
+
|
|
67
|
+
set -u
|
|
68
|
+
|
|
69
|
+
SUB="${1:-}"
|
|
70
|
+
case "$SUB" in
|
|
71
|
+
extract|classify|file-prepare|file-verify|verdict|calibrate|merge-gate|demilestone|integrity) shift ;;
|
|
72
|
+
*) echo "usage: review-finalize.sh extract|classify|file-prepare|file-verify|verdict|calibrate|merge-gate|demilestone|integrity --pr N --repo owner/name --context FILE [options]" >&2; exit 2 ;;
|
|
73
|
+
esac
|
|
74
|
+
|
|
75
|
+
PR_NUMBER=""; REPO=""; CONTEXT=""; OUT_DIR=""; IN_FILE=""; OUT_FILE=""; AUTO_MERGE="false"; NOTES_FIXED=0; NOTES_DROPPED=0; PATTERNS=""; DRY_RUN="false"; SCOPE_FILES=""; PRIOR_PRS=""; BUNDLE="false"
|
|
76
|
+
REVIEW_SHA_OPT=""; REVIEW_MODE_OPT=""; BASE_OPT=""; HEAD_BRANCH=""; ISSUE_OPT=""; VERDICT=""; HAS_PURPOSE_REGRESSION=""; CALIBRATION_NEEDS_HUMAN=""; CALIBRATION_NOTE=""; TRUST_NEEDS_HUMAN=""
|
|
77
|
+
BASE_CONFLICT=""; OTHER_BLOCKING=""; INTENSITY_TIER=""; TABLE_CELL_KEY=""; TRUST_SHADOW_MODE=""; MERGE_HEALTH=""; MERGE_HEALTH_STATE=""; RESULT=""
|
|
78
|
+
AGENTS_TEXT=""; FINDINGS_COUNT=""; BLOCKING_COUNT=""; PURPOSE_FINDINGS=""; IS_MILESTONE="false"; MERGE_STATE_IN=""
|
|
79
|
+
while [ $# -gt 0 ]; do
|
|
80
|
+
case "$1" in
|
|
81
|
+
--pr|--repo|--context|--out-dir|--findings|--out|--auto-merge|--notes-fixed|--notes-dropped|--patterns|--base|--head-branch|--issue|--verdict|--has-purpose-regression|--calibration-needs-human|--calibration-note|--trust-needs-human|--base-conflict|--other-blocking|--intensity-tier|--table-cell-key|--trust-shadow-mode|--merge-health|--merge-health-state|--result|--agents|--findings-count|--blocking-count|--purpose-findings|--is-milestone|--merge-state|--review-sha|--review-mode|--scope-files|--prior-prs)
|
|
82
|
+
[ $# -ge 2 ] || { echo "review-finalize: $1 requires a value" >&2; exit 2; } ;;
|
|
83
|
+
esac
|
|
84
|
+
case "$1" in
|
|
85
|
+
--pr) PR_NUMBER="${2:-}"; shift 2 ;;
|
|
86
|
+
--repo) REPO="${2:-}"; shift 2 ;;
|
|
87
|
+
--context) CONTEXT="${2:-}"; shift 2 ;;
|
|
88
|
+
--out-dir) OUT_DIR="${2:-}"; shift 2 ;;
|
|
89
|
+
--findings) IN_FILE="${2:-}"; shift 2 ;;
|
|
90
|
+
--out) OUT_FILE="${2:-}"; shift 2 ;;
|
|
91
|
+
--auto-merge) AUTO_MERGE="${2:-false}"; shift 2 ;;
|
|
92
|
+
--notes-fixed) NOTES_FIXED="${2:-0}"; shift 2 ;;
|
|
93
|
+
--notes-dropped) NOTES_DROPPED="${2:-0}"; shift 2 ;;
|
|
94
|
+
--patterns) PATTERNS="${2:-}"; shift 2 ;;
|
|
95
|
+
--review-sha) REVIEW_SHA_OPT="${2:-}"; shift 2 ;;
|
|
96
|
+
--review-mode) REVIEW_MODE_OPT="${2:-}"; shift 2 ;;
|
|
97
|
+
--base) BASE_OPT="${2:-}"; shift 2 ;;
|
|
98
|
+
--head-branch) HEAD_BRANCH="${2:-}"; shift 2 ;;
|
|
99
|
+
--issue) ISSUE_OPT="${2:-}"; shift 2 ;;
|
|
100
|
+
--verdict) VERDICT="${2:-}"; shift 2 ;;
|
|
101
|
+
--has-purpose-regression) HAS_PURPOSE_REGRESSION="${2:-}"; shift 2 ;;
|
|
102
|
+
--calibration-needs-human) CALIBRATION_NEEDS_HUMAN="${2:-}"; shift 2 ;;
|
|
103
|
+
--calibration-note) CALIBRATION_NOTE="${2:-}"; shift 2 ;;
|
|
104
|
+
--trust-needs-human) TRUST_NEEDS_HUMAN="${2:-}"; shift 2 ;;
|
|
105
|
+
--base-conflict) BASE_CONFLICT="${2:-}"; shift 2 ;;
|
|
106
|
+
--other-blocking) OTHER_BLOCKING="${2:-}"; shift 2 ;;
|
|
107
|
+
--intensity-tier) INTENSITY_TIER="${2:-}"; shift 2 ;;
|
|
108
|
+
--table-cell-key) TABLE_CELL_KEY="${2:-}"; shift 2 ;;
|
|
109
|
+
--trust-shadow-mode) TRUST_SHADOW_MODE="${2:-}"; shift 2 ;;
|
|
110
|
+
--merge-health) MERGE_HEALTH="${2:-}"; shift 2 ;;
|
|
111
|
+
--merge-health-state) MERGE_HEALTH_STATE="${2:-}"; shift 2 ;;
|
|
112
|
+
--result) RESULT="${2:-}"; shift 2 ;;
|
|
113
|
+
--agents) AGENTS_TEXT="${2:-}"; shift 2 ;;
|
|
114
|
+
--findings-count) FINDINGS_COUNT="${2:-}"; shift 2 ;;
|
|
115
|
+
--blocking-count) BLOCKING_COUNT="${2:-}"; shift 2 ;;
|
|
116
|
+
--purpose-findings) PURPOSE_FINDINGS="${2:-}"; shift 2 ;;
|
|
117
|
+
--is-milestone) IS_MILESTONE="${2:-false}"; shift 2 ;;
|
|
118
|
+
--merge-state) MERGE_STATE_IN="${2:-}"; shift 2 ;;
|
|
119
|
+
--scope-files) SCOPE_FILES="${2:-}"; shift 2 ;;
|
|
120
|
+
--prior-prs) PRIOR_PRS="${2:-}"; BUNDLE="true"; shift 2 ;;
|
|
121
|
+
--dry-run) DRY_RUN="true"; shift ;;
|
|
122
|
+
*) echo "review-finalize: unknown argument: $1" >&2; exit 2 ;;
|
|
123
|
+
esac
|
|
124
|
+
done
|
|
125
|
+
|
|
126
|
+
# --- file-verify needs nothing else: it only parses the issue-creation result text on stdin ---
|
|
127
|
+
if [ "$SUB" = "file-verify" ]; then
|
|
128
|
+
ISSUE_SKILL_OUTPUT=$(cat)
|
|
129
|
+
# The creation step succeeds only after its API create-token read-back; its explicit result marker distinguishes a verified create
|
|
130
|
+
# from an intentional dedup STOP. Never use title search to mask a swallowed 403.
|
|
131
|
+
ISSUE_NUM=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:CREATED number=\([0-9][0-9]*\).*/\1/p' | head -1)
|
|
132
|
+
DEDUP_NUMBER=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:DEDUP number=\([0-9][0-9]*\).*/\1/p' | head -1)
|
|
133
|
+
if [ -n "$DEDUP_NUMBER" ]; then
|
|
134
|
+
echo "Review finding deduped against existing issue #${DEDUP_NUMBER}."
|
|
135
|
+
echo "ISSUE_NUM=${DEDUP_NUMBER}"; echo "DEDUPED=true"; exit 0
|
|
136
|
+
elif [ -z "$ISSUE_NUM" ]; then
|
|
137
|
+
echo "ERROR: /issue did not report a verified review-finding number; stopping review instead of silently dropping the finding." >&2
|
|
138
|
+
exit 1
|
|
139
|
+
fi
|
|
140
|
+
echo "ISSUE_NUM=${ISSUE_NUM}"; echo "DEDUPED=false"; exit 0
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-finalize: --pr must be numeric" >&2; exit 2 ;; esac
|
|
144
|
+
case "$REPO" in */*) ;; *) echo "review-finalize: --repo must be owner/name" >&2; exit 2 ;; esac
|
|
145
|
+
case "$REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/) echo "review-finalize: invalid --repo" >&2; exit 2 ;; esac
|
|
146
|
+
# Phase 6 subcommands need the review-prepare context; the Phase 7-9 subcommands take it when given and otherwise run from flags
|
|
147
|
+
# (--review-sha, --review-mode, --issue, --base), so their spec fences stand alone.
|
|
148
|
+
case "$SUB" in
|
|
149
|
+
extract|classify|file-prepare) [ -n "$CONTEXT" ] || { echo "review-finalize: --context FILE is required" >&2; exit 2; } ;;
|
|
150
|
+
esac
|
|
151
|
+
case "$NOTES_FIXED" in ''|*[!0-9]*) NOTES_FIXED=0 ;; esac
|
|
152
|
+
case "$NOTES_DROPPED" in ''|*[!0-9]*) NOTES_DROPPED=0 ;; esac
|
|
153
|
+
[ "$AUTO_MERGE" = "true" ] || AUTO_MERGE="false"
|
|
154
|
+
[ "$IS_MILESTONE" = "true" ] || IS_MILESTONE="false"
|
|
155
|
+
case "$RESULT" in ''|stale|clean|blocking) ;; *) echo "review-finalize: --result must be stale|clean|blocking" >&2; exit 2 ;; esac
|
|
156
|
+
case "$FINDINGS_COUNT" in *[!0-9]*) FINDINGS_COUNT="" ;; esac
|
|
157
|
+
case "$BLOCKING_COUNT" in *[!0-9]*) BLOCKING_COUNT="" ;; esac
|
|
158
|
+
command -v jq >/dev/null 2>&1 || { echo "review-finalize: jq is required" >&2; exit 2; }
|
|
159
|
+
|
|
160
|
+
# --- trusted helper resolution (install roots only; never cwd) ---
|
|
161
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
162
|
+
|
|
163
|
+
# Every fail-closed path prints the spec's marker text byte-identically, then exits 3 so the caller cannot skip it.
|
|
164
|
+
blocked() { echo "REVIEW_RESULT: status: BLOCKED, blocker: $1"; exit 3; }
|
|
165
|
+
|
|
166
|
+
# --- the review-prepare context (schema_version 1) ---
|
|
167
|
+
REVIEW_SHA="$REVIEW_SHA_OPT"; REVIEW_MODE="${REVIEW_MODE_OPT:-FULL}"; DELTA_FROM=""; MERGE_ISSUE=""; CTX_BASE_BRANCH=""
|
|
168
|
+
if [ -n "$CONTEXT" ]; then
|
|
169
|
+
[ -r "$CONTEXT" ] || blocked "review-prepare context unreadable: $CONTEXT"
|
|
170
|
+
jq -e . "$CONTEXT" >/dev/null 2>&1 || blocked "review-prepare context is not valid JSON"
|
|
171
|
+
CTX_SV=$(jq -r '.schema_version // empty' "$CONTEXT" 2>/dev/null)
|
|
172
|
+
[ "$CTX_SV" = "1" ] || blocked "review-prepare context schema_version ${CTX_SV:-missing} is not supported (expected 1)"
|
|
173
|
+
[ "$(jq -r '.status // empty' "$CONTEXT")" != "BLOCKED" ] || blocked "review-prepare context is BLOCKED: $(jq -r '(.blockers // []) | join("; ")' "$CONTEXT")"
|
|
174
|
+
REVIEW_SHA=$(jq -r '.pr.review_sha // empty' "$CONTEXT")
|
|
175
|
+
REVIEW_MODE=$(jq -r '.delta.mode // "FULL"' "$CONTEXT")
|
|
176
|
+
DELTA_FROM=$(jq -r '.delta.from // empty' "$CONTEXT")
|
|
177
|
+
# Never the context's contract.issue: review-prepare reads it from the PR body (author-controlled), and the fixing
|
|
178
|
+
# issue drives lineage damping, contract demotion and the cap. Only the invoker's --issue sets it (#3952 SEC-1/QA-2).
|
|
179
|
+
MERGE_ISSUE=""
|
|
180
|
+
CTX_BASE_BRANCH=$(jq -r '.pr.base_ref // empty' "$CONTEXT")
|
|
181
|
+
fi
|
|
182
|
+
# --issue is the invoker's own --issue (never PR-author text); a non-numeric value is ignored like the context's.
|
|
183
|
+
case "$ISSUE_OPT" in ''|*[!0-9]*) ;; *) MERGE_ISSUE="$ISSUE_OPT" ;; esac
|
|
184
|
+
# A staging bundle review (--prior-prs) has no single fixing issue, whatever was passed.
|
|
185
|
+
[ "$BUNDLE" != "true" ] || MERGE_ISSUE=""
|
|
186
|
+
case "$REVIEW_MODE" in FULL|DELTA|BASE_SYNC_ONLY) ;; *) REVIEW_MODE="FULL" ;; esac
|
|
187
|
+
|
|
188
|
+
[ -n "$OUT_DIR" ] || OUT_DIR="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-finalize-${PR_NUMBER}"
|
|
189
|
+
mkdir -p "$OUT_DIR" 2>/dev/null || { echo "review-finalize: cannot create $OUT_DIR" >&2; exit 2; }
|
|
190
|
+
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/review-finalize.XXXXXX") || exit 2
|
|
191
|
+
trap 'rm -rf "$WORKDIR"' EXIT
|
|
192
|
+
_pscr="$WORKDIR"
|
|
193
|
+
|
|
194
|
+
is_sha() { case "${1}" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]; }
|
|
195
|
+
# Shared: validate the input findings JSON for classify and file-prepare.
|
|
196
|
+
need_findings() {
|
|
197
|
+
[ -r "$IN_FILE" ] && jq -e '.findings | type == "array"' "$IN_FILE" >/dev/null 2>&1 \
|
|
198
|
+
|| { echo "review-finalize: findings JSON unreadable or malformed: $IN_FILE" >&2; exit 2; }
|
|
199
|
+
}
|
|
200
|
+
fj() { jq -r --argjson i "$I" ".findings[\$i]$1 // empty" "$IN_FILE" 2>/dev/null; } # one field of finding I
|
|
201
|
+
upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
|
|
202
|
+
sev_rank() { case "$(upper "$1")" in CRITICAL) echo 0 ;; HIGH) echo 1 ;; MEDIUM) echo 2 ;; LOW) echo 3 ;; *) echo 4 ;; esac; }
|
|
203
|
+
|
|
204
|
+
# ======================================================================================================================
|
|
205
|
+
# extract: 6A + 6B + 6B.4
|
|
206
|
+
# ======================================================================================================================
|
|
207
|
+
do_extract() {
|
|
208
|
+
OUT_FILE="${OUT_FILE:-$OUT_DIR/findings.json}"
|
|
209
|
+
case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
|
|
210
|
+
[ "$REVIEW_SHA_OK" = "true" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id (an empty SHA would count every head's findings)"; exit 3; }
|
|
211
|
+
# Only TRUSTED bodies for the CURRENT head count (BASE_SYNC_ONLY and DELTA add the carried / earlier reviewed head, see below) (scripts/trusted-comments.sh + anchored marker + Reviewed-SHA line):
|
|
212
|
+
# findings from earlier heads are already fixed or re-reviewed, and an untrusted commenter must never be able to inject
|
|
213
|
+
# findings or post a forged synthesis block that makes the review read clean (the head SHA is public).
|
|
214
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
215
|
+
REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
|
|
216
|
+
BASE_BRANCH="$CTX_BASE_BRANCH"
|
|
217
|
+
[ -n "$BASE_BRANCH" ] || BASE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
218
|
+
COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
|
|
219
|
+
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
|
|
220
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"; exit 3
|
|
221
|
+
fi
|
|
222
|
+
_dm=$(forge_resolve lib/review-delta-mode.sh); [ -n "$_dm" ] && . "$_dm" || { review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; }
|
|
223
|
+
# Trusted synthesized block for one head when present, else that head's trusted agent bodies (the same predicate as before,
|
|
224
|
+
# now per head). Returns non-zero when the trust script cannot count or read.
|
|
225
|
+
bodies_at() {
|
|
226
|
+
local sha="${1}" synth agent has
|
|
227
|
+
synth="^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${sha}(\\r?\\n|$)"
|
|
228
|
+
agent="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${sha}(\\r?\\n|$)"
|
|
229
|
+
has=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "$synth" 2>/dev/null || echo "")
|
|
230
|
+
case "$has" in ''|*[!0-9]*) return 1 ;; esac
|
|
231
|
+
if [ "$has" -gt 0 ]; then
|
|
232
|
+
printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$synth" 2>/dev/null || return 1
|
|
233
|
+
else
|
|
234
|
+
printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$agent" 2>/dev/null || return 1
|
|
235
|
+
fi
|
|
236
|
+
}
|
|
237
|
+
# Trusted raw agent bodies for one head (the carrier of each finding's agent name and prose, even when a synthesis block is the source).
|
|
238
|
+
agent_bodies_at() {
|
|
239
|
+
printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${1}(\\r?\\n|$)" 2>/dev/null || true
|
|
240
|
+
}
|
|
241
|
+
# FULL: the current head's bodies, exactly as before. BASE_SYNC_ONLY: the head has no reviewers, so the findings are the
|
|
242
|
+
# carried head's, and only a VERIFIED carry record (trusted, same head, resolver agrees) selects that SHA. DELTA: reviewers
|
|
243
|
+
# saw only the delta, so the earlier fully reviewed head's findings are read too (they must still reach the Phase 8 gates);
|
|
244
|
+
# the 6B.4 head-SHA and provenance gates then drop any whose cited line is gone from the current head.
|
|
245
|
+
PRIMARY_SHA="$REVIEW_SHA"; EARLIER_SHA=""
|
|
246
|
+
if [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ]; then
|
|
247
|
+
CARRIED_FROM=$(carry_verified_from); [ -n "$CARRIED_FROM" ] && PRIMARY_SHA="$CARRIED_FROM"
|
|
248
|
+
fi
|
|
249
|
+
if [ "$REVIEW_MODE" = "DELTA" ] && is_sha "$DELTA_FROM"; then EARLIER_SHA="$DELTA_FROM"; fi
|
|
250
|
+
SOURCE_BODIES=$(bodies_at "$PRIMARY_SHA") \
|
|
251
|
+
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted reviewer comments unreadable (fail closed)"; exit 3; }
|
|
252
|
+
# The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
|
|
253
|
+
[ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${PRIMARY_SHA} (fail closed)"; exit 3; }
|
|
254
|
+
CARRIER_BODIES=$(agent_bodies_at "$PRIMARY_SHA")
|
|
255
|
+
if [ -n "$EARLIER_SHA" ]; then
|
|
256
|
+
EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
|
|
257
|
+
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
|
|
258
|
+
SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
|
|
259
|
+
CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
|
|
260
|
+
fi
|
|
261
|
+
# Match to the closing ` -->`, never `[^>]+`: a `>` or `->` inside a title must not drop the finding (#3897).
|
|
262
|
+
FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
|
|
263
|
+
# Fail closed when a raw marker did not parse: a lost finding must never read as a clean review.
|
|
264
|
+
N_RAW_MARKERS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r '[scan("<!-- FINDING:")] | length' 2>/dev/null | awk '{ s += $1 } END { print s + 0 }')
|
|
265
|
+
N_PARSED_MARKERS=$(printf '%s\n' "$FINDINGS" | grep -c . || true)
|
|
266
|
+
[ "$N_RAW_MARKERS" -eq "$N_PARSED_MARKERS" ] \
|
|
267
|
+
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: ${N_RAW_MARKERS} FINDING markers but ${N_PARSED_MARKERS} parsed (malformed marker, fail closed)"; exit 3; }
|
|
268
|
+
|
|
269
|
+
# Carrier lookup order: raw agent bodies first (they carry the agent marker and the per-finding prose), then the source bodies.
|
|
270
|
+
printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
|
|
271
|
+
|
|
272
|
+
RAW="$WORKDIR/raw.jsonl"; : > "$RAW"; N_EXTRACTED=0
|
|
273
|
+
while IFS='|' read -r F_ID F_CONF F_SEV F_LOC F_TITLE; do
|
|
274
|
+
[ -n "$F_ID" ] || continue
|
|
275
|
+
N_EXTRACTED=$((N_EXTRACTED + 1))
|
|
276
|
+
# file[:N[-M]] -> file, line start, line end (a non-numeric suffix stays part of the file name).
|
|
277
|
+
F_FILE="$F_LOC"; L1=""; L2=""
|
|
278
|
+
case "$F_LOC" in
|
|
279
|
+
*:[0-9]*) _r="${F_LOC##*:}"
|
|
280
|
+
case "$_r" in *[!0-9-]*|"") ;; *) F_FILE="${F_LOC%:*}"; L1="${_r%%-*}"; L2="${_r#*-}"; [ "$L2" != "$_r" ] || L2="$L1" ;; esac ;;
|
|
281
|
+
esac
|
|
282
|
+
case "$L1" in *[!0-9]*) L1=""; L2="" ;; esac; case "$L2" in *[!0-9]*) L2="$L1" ;; esac
|
|
283
|
+
# The carrying comment: the first trusted body that holds this finding's marker.
|
|
284
|
+
BODY_FILE="$WORKDIR/body.$N_EXTRACTED"
|
|
285
|
+
jq -rs --arg m "<!-- FINDING:${F_ID}|" 'map(select(contains($m))) | .[0] // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
|
|
286
|
+
AGENT=$(sed -n 's/^<!-- FORGE:REVIEW-AGENT:\([a-z-]*\) -->.*/\1/p' "$BODY_FILE" | head -n 1)
|
|
287
|
+
# The finding's own prose section: from the first non-marker line naming the id to the next heading / marker / other finding id.
|
|
288
|
+
SECTION_FILE="$WORKDIR/section.$N_EXTRACTED"
|
|
289
|
+
awk -v id="$F_ID" '
|
|
290
|
+
function hasid(l, p, pre, post, rest, off) {
|
|
291
|
+
rest = l; off = 0
|
|
292
|
+
while ((p = index(rest, id)) > 0) {
|
|
293
|
+
pre = (off + p > 1) ? substr(l, off + p - 1, 1) : ""
|
|
294
|
+
post = substr(rest, p + length(id), 1)
|
|
295
|
+
if (pre !~ /[A-Za-z0-9-]/ && post !~ /[0-9A-Za-z]/) return 1
|
|
296
|
+
off += p; rest = substr(rest, p + 1)
|
|
297
|
+
}
|
|
298
|
+
return 0
|
|
299
|
+
}
|
|
300
|
+
/<!-- (FINDING:|REVIEW-FINDINGS)/ { if (started) exit; next }
|
|
301
|
+
!started { if (hasid($0)) { started = 1; print; first = 0; n = 1 } ; next }
|
|
302
|
+
{ n++
|
|
303
|
+
if ($0 ~ /^#/) exit
|
|
304
|
+
if (match($0, /^[-*#> \t]*\**[A-Z][A-Z0-9]*-[0-9]+/) && !hasid($0)) exit
|
|
305
|
+
print }' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
|
|
306
|
+
# Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
|
|
307
|
+
SCENARIO="unknown"
|
|
308
|
+
if [ -s "$SECTION_FILE" ]; then
|
|
309
|
+
SCENARIO=$(awk '
|
|
310
|
+
tolower($0) ~ /\*\*failure scenario\*\*/ { s = $0; sub(/^.*\*\*[Ff]ailure [Ss]cenario\*\*[*:[:space:]]*/, "", s); gsub(/[[:space:]]/, "", s)
|
|
311
|
+
if (s != "") { print "present"; found = 1; exit } else { want = 1; next } }
|
|
312
|
+
want { t = $0; gsub(/[[:space:]]/, "", t); if (t != "") { print "present"; found = 1; exit } }
|
|
313
|
+
END { if (!found) print "absent" }' "$SECTION_FILE")
|
|
314
|
+
fi
|
|
315
|
+
FIX_EFF=$(grep -iE '^[[:space:]]*(\*\*Fix efficacy\*\*:?|FIX_EFFICACY:)[[:space:]]*\**[[:space:]]*(fails|regresses)' "$SECTION_FILE" 2>/dev/null | head -n 1 \
|
|
316
|
+
| tr '[:upper:]' '[:lower:]' | sed -E 's/.*(fails|regresses).*/\1/')
|
|
317
|
+
TEXT_FILE="$WORKDIR/text.$N_EXTRACTED"
|
|
318
|
+
{ printf '%s\n%s\n' "$F_TITLE" "$F_FILE"; cat "$SECTION_FILE" 2>/dev/null; } > "$TEXT_FILE"
|
|
319
|
+
jq -n -c --arg id "$F_ID" --arg c "$F_CONF" --arg s "$F_SEV" --arg loc "$F_LOC" --arg file "$F_FILE" --arg l1 "$L1" --arg l2 "$L2" \
|
|
320
|
+
--arg title "$F_TITLE" --arg agent "$AGENT" --arg scen "$SCENARIO" --arg fe "$FIX_EFF" --rawfile text "$TEXT_FILE" \
|
|
321
|
+
'{id: $id, confidence: $c, severity: $s, loc: $loc, file: $file, line_start: ($l1 | tonumber? // null), line_end: ($l2 | tonumber? // null),
|
|
322
|
+
title: $title, agent: $agent, scenario: $scen, fix_efficacy: $fe, text: $text}' >> "$RAW"
|
|
323
|
+
done <<EOF
|
|
324
|
+
$FINDINGS
|
|
325
|
+
EOF
|
|
326
|
+
|
|
327
|
+
# 6B: dedup by file + line range +-5 (keep the higher confidence) and by same file + 3 shared title keywords; CONFIRMED first, then
|
|
328
|
+
# LIKELY, then POSSIBLE, within a group by severity. Sorting first means a kept finding is always at least as confident as a dropped one.
|
|
329
|
+
DEDUPED="$WORKDIR/deduped.json"
|
|
330
|
+
jq -s '
|
|
331
|
+
def up: (. // "") | ascii_upcase | gsub("\\s"; "");
|
|
332
|
+
def cr: (.confidence | up) as $c | if $c == "CONFIRMED" then 0 elif $c == "LIKELY" then 1 elif $c == "POSSIBLE" then 2 else 3 end;
|
|
333
|
+
def sr: (.severity | up) as $s | if $s == "CRITICAL" then 0 elif $s == "HIGH" then 1 elif $s == "MEDIUM" then 2 elif $s == "LOW" then 3 else 4 end;
|
|
334
|
+
def kw: (.title | ascii_downcase | [scan("[a-z0-9_]+")] | map(select(length > 3)) | unique);
|
|
335
|
+
def shared($a; $b): [$a[] | select(. as $w | any($b[]; . == $w))] | length;
|
|
336
|
+
to_entries | map(.value + {_i: .key}) | sort_by([cr, sr, ._i])
|
|
337
|
+
| reduce .[] as $f ([];
|
|
338
|
+
if any(.[]; . as $k | $k.file == $f.file and (
|
|
339
|
+
(($f.line_start != null and $k.line_start != null) and ((($k.line_start - $f.line_start) | if . < 0 then -. else . end) <= 5))
|
|
340
|
+
or (shared($f | kw; $k | kw) >= 3)))
|
|
341
|
+
then . else . + [$f] end)
|
|
342
|
+
| map(del(._i))' "$RAW" > "$DEDUPED" 2>/dev/null || { echo "[]" > "$DEDUPED"; }
|
|
343
|
+
N_DEDUPED=$(jq 'length' "$DEDUPED")
|
|
344
|
+
|
|
345
|
+
# 6B.4: provenance and concreteness gates (head-SHA, introduced-line, concrete-failure), in this order. Every gate fails toward keeping.
|
|
346
|
+
# Paths come from the files API (raw names), never from `+++` header lines: those are quoted/tab-suffixed for special names.
|
|
347
|
+
FILES_JSON="$WORKDIR/pr-files.json"
|
|
348
|
+
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate > "$FILES_JSON" 2>/dev/null || : > "$FILES_JSON"
|
|
349
|
+
# "path:line" for every added line; an empty file means the diff was unreadable (gates then keep every finding).
|
|
350
|
+
ADDED_LINES_FILE="$WORKDIR/added-lines"
|
|
351
|
+
jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk '
|
|
352
|
+
/^FILE\t/{f=substr($(0),6);next}
|
|
353
|
+
/^@@/{s=$(0);sub(/^@@ -[0-9,]+ \+/,"",s);sub(/[ ,].*/,"",s);n=s+0;next}
|
|
354
|
+
/^\\/{next}
|
|
355
|
+
/^\+/{print f":"n;n++;next}
|
|
356
|
+
/^-/{next}
|
|
357
|
+
{n++}' > "$ADDED_LINES_FILE" || : > "$ADDED_LINES_FILE"
|
|
358
|
+
# Files the PR deleted or renamed (the old name too): findings on them always intersect.
|
|
359
|
+
GONE_FILES_FILE="$WORKDIR/gone-files"
|
|
360
|
+
jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
|
|
361
|
+
# Files present with no `patch` (large/truncated diff): their added lines are UNKNOWN, so findings on them are always kept.
|
|
362
|
+
NOPATCH_FILES_FILE="$WORKDIR/nopatch-files"
|
|
363
|
+
jq -r '.[] | select(.patch == null and .status != "removed") | .filename' "$FILES_JSON" > "$NOPATCH_FILES_FILE" 2>/dev/null || : > "$NOPATCH_FILES_FILE"
|
|
364
|
+
PR_NAMES_FILE="$WORKDIR/pr-names"
|
|
365
|
+
jq -r '.[] | .filename' "$FILES_JSON" > "$PR_NAMES_FILE" 2>/dev/null || : > "$PR_NAMES_FILE"
|
|
366
|
+
|
|
367
|
+
# Line count of FILE at REVIEW_SHA: a number, ABSENT (the commit is local and the path is not in it) or UNKNOWN (a failed probe is never 0).
|
|
368
|
+
file_lines_at_sha() {
|
|
369
|
+
local f="$1" t="$WORKDIR/at-sha"
|
|
370
|
+
if git rev-parse -q --verify "${REVIEW_SHA}^{commit}" >/dev/null 2>&1; then
|
|
371
|
+
if git show "${REVIEW_SHA}:${f}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; return 0; fi
|
|
372
|
+
if git cat-file -e "${REVIEW_SHA}:${f}" 2>/dev/null; then echo UNKNOWN; else echo ABSENT; fi
|
|
373
|
+
return 0
|
|
374
|
+
fi
|
|
375
|
+
case "$f" in *[!A-Za-z0-9._/@+-]*|"") echo UNKNOWN; return 0 ;; esac
|
|
376
|
+
if gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${REVIEW_SHA}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; else echo UNKNOWN; fi
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
|
|
380
|
+
N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_KEPT=0
|
|
381
|
+
I=0; TOTAL=$(jq 'length' "$DEDUPED")
|
|
382
|
+
while [ "$I" -lt "$TOTAL" ]; do
|
|
383
|
+
F=$(jq -c ".[$I]" "$DEDUPED"); I=$((I + 1))
|
|
384
|
+
FILE=$(printf '%s' "$F" | jq -r '.file'); SEV=$(upper "$(printf '%s' "$F" | jq -r '.severity')")
|
|
385
|
+
LINE=$(printf '%s' "$F" | jq -r '.line_start // empty'); SCEN=$(printf '%s' "$F" | jq -r '.scenario')
|
|
386
|
+
ROUTE="file"
|
|
387
|
+
# Validate before any arithmetic: never expand reviewer text in $(( )).
|
|
388
|
+
case "$LINE" in ""|*[!0-9]*) LINE="" ;; esac
|
|
389
|
+
GONE=0; NOPATCH=0
|
|
390
|
+
grep -qxF -- "$FILE" "$GONE_FILES_FILE" && GONE=1
|
|
391
|
+
grep -qxF -- "$FILE" "$NOPATCH_FILES_FILE" && NOPATCH=1
|
|
392
|
+
# Gate 1 (head-SHA): the cited line must exist at REVIEW_SHA. A file the PR deleted or renamed is kept (checked first).
|
|
393
|
+
if [ "$GONE" -eq 0 ] && [ -n "$LINE" ]; then
|
|
394
|
+
AT=$(file_lines_at_sha "$FILE")
|
|
395
|
+
STALE=0
|
|
396
|
+
case "$AT" in
|
|
397
|
+
UNKNOWN|"") ;;
|
|
398
|
+
ABSENT) if [ -s "$PR_NAMES_FILE" ] && ! grep -qxF -- "$FILE" "$PR_NAMES_FILE"; then STALE=1; fi ;;
|
|
399
|
+
*[!0-9]*) ;;
|
|
400
|
+
*) [ "$LINE" -gt "$AT" ] && STALE=1 ;;
|
|
401
|
+
esac
|
|
402
|
+
if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
|
|
403
|
+
fi
|
|
404
|
+
# Gate 2 (introduced-line): MEDIUM or lower with no added line within +-5 is pre-existing. CRITICAL/HIGH and unknown severities never are.
|
|
405
|
+
if [ -n "$LINE" ] && [ "$GONE" -eq 0 ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
|
|
406
|
+
case "$SEV" in
|
|
407
|
+
MEDIUM|LOW|INFO)
|
|
408
|
+
LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
|
|
409
|
+
while [ "$L" -le $((LINE + 5)) ]; do
|
|
410
|
+
if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
|
|
411
|
+
L=$((L + 1))
|
|
412
|
+
done
|
|
413
|
+
[ "$INTRODUCED" -eq 1 ] || ROUTE="pre-existing" ;;
|
|
414
|
+
esac
|
|
415
|
+
fi
|
|
416
|
+
# Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
|
|
417
|
+
# CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
|
|
418
|
+
if [ "$ROUTE" = "file" ] && [ "$SCEN" = "absent" ]; then
|
|
419
|
+
case "$SEV" in MEDIUM|LOW|INFO) ROUTE="demoted-no-scenario" ;; esac
|
|
420
|
+
fi
|
|
421
|
+
case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
|
|
422
|
+
N_KEPT=$((N_KEPT + 1))
|
|
423
|
+
printf '%s' "$F" | jq -c --arg r "$ROUTE" '. + {route: $r}' >> "$FINAL"
|
|
424
|
+
done
|
|
425
|
+
|
|
426
|
+
jq -s --arg sha "$REVIEW_SHA" --arg mode "$REVIEW_MODE" --arg mi "$MERGE_ISSUE" \
|
|
427
|
+
--argjson ex "$N_EXTRACTED" --argjson dd "$N_DEDUPED" --argjson st "$N_STALE" --argjson pe "$N_PREEXISTING" --argjson ns "$N_NO_SCENARIO" \
|
|
428
|
+
'{schema_version: 1, review_sha: $sha, review_mode: $mode, merge_issue: ($mi | tonumber? // null),
|
|
429
|
+
counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, notes_demoted_no_scenario: $ns},
|
|
430
|
+
findings: .}' "$FINAL" > "$OUT_FILE" || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
|
|
431
|
+
echo "FINDINGS_OUT=$OUT_FILE"
|
|
432
|
+
echo "STRUCTURED_FINDINGS=$N_EXTRACTED"
|
|
433
|
+
echo "FINDINGS_KEPT=$N_KEPT"
|
|
434
|
+
echo "FINDINGS_DROPPED_STALE=$N_STALE"
|
|
435
|
+
echo "FINDINGS_PREEXISTING=$N_PREEXISTING"
|
|
436
|
+
echo "NOTES_DEMOTED_NO_SCENARIO=$N_NO_SCENARIO"
|
|
437
|
+
[ "$N_EXTRACTED" -gt 0 ] || echo "CLEAN_STRUCTURED=true" # 0 structured findings: the caller scans unstructured text, then skips to Phase 7 if still 0
|
|
438
|
+
exit 0
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
# ======================================================================================================================
|
|
442
|
+
# classify: 6B.5 + 6B.6
|
|
443
|
+
# ======================================================================================================================
|
|
444
|
+
do_classify() {
|
|
445
|
+
IN_FILE="${IN_FILE:-$OUT_DIR/findings.json}"; OUT_FILE="${OUT_FILE:-$OUT_DIR/classified.json}"
|
|
446
|
+
need_findings
|
|
447
|
+
case "$REVIEW_SHA" in *[!0-9a-f]*|"") echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id"; exit 3 ;; esac
|
|
448
|
+
CLASSIFY_SCRIPT=$(forge_resolve classify-finding.sh)
|
|
449
|
+
# Fail closed (#3639): no hand classification. An unresolvable classifier ends the review here.
|
|
450
|
+
if [ -z "$CLASSIFY_SCRIPT" ]; then
|
|
451
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: classifier unavailable (classify-finding.sh not resolvable through the trusted resolver)"
|
|
452
|
+
exit 3
|
|
453
|
+
fi
|
|
454
|
+
export FORGE_REPO="$REPO" FORGE_PR="$PR_NUMBER" FORGE_ISSUE="${MERGE_ISSUE:-}" # forge#3834: classifier telemetry context
|
|
455
|
+
# Per-review cap (#3642): same trusted resolver, same fail-closed rule. An unresolvable cap helper would file unbounded, so it ends the review.
|
|
456
|
+
CAP_SCRIPT=$(forge_resolve cap-findings.sh)
|
|
457
|
+
if [ -z "$CAP_SCRIPT" ]; then
|
|
458
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: cap helper unavailable (cap-findings.sh not resolvable through the trusted resolver)"
|
|
459
|
+
exit 3
|
|
460
|
+
fi
|
|
461
|
+
# forge.yaml review.max_filed_findings (optional; absent = the helper default: 2, or 1 on review-finding lineage). Never validate here: the helper falls back on an invalid value.
|
|
462
|
+
MAX_FILED_FINDINGS=$(yq '.review.max_filed_findings // ""' "${FORGE_CONFIG:-forge.yaml}" 2>/dev/null || echo '')
|
|
463
|
+
FINDING_LINEAGE="none"
|
|
464
|
+
if [ -n "${MERGE_ISSUE:-}" ] && gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels --jq '.labels[].name' 2>/dev/null | grep -qx 'review-finding'; then
|
|
465
|
+
FINDING_LINEAGE="review-finding"
|
|
466
|
+
fi
|
|
467
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
468
|
+
# In-PR fix gate (6B.6): only for an auto-merge review with a linked issue, and only for the first
|
|
469
|
+
# fix round on this PR. Any trusted FORGE:INPR_FIX* marker already on the PR means the round is used.
|
|
470
|
+
INPR_DIFF_FILE=""
|
|
471
|
+
if [ "${AUTO_MERGE:-false}" = "true" ] && [ -n "${MERGE_ISSUE:-}" ]; then
|
|
472
|
+
if [ -n "$TRUSTED_SCRIPT" ]; then INPR_MARKERS=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
|
|
473
|
+
| bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:INPR_FIX' 2>/dev/null || echo ""); else INPR_MARKERS=""; fi
|
|
474
|
+
if [ "$INPR_MARKERS" = "0" ]; then
|
|
475
|
+
INPR_DIFF_FILE="$WORKDIR/inpr-diff"
|
|
476
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$INPR_DIFF_FILE" 2>/dev/null || { rm -f "$INPR_DIFF_FILE"; INPR_DIFF_FILE=""; }
|
|
477
|
+
fi
|
|
478
|
+
# An unreadable marker list or diff leaves INPR_DIFF_FILE empty: findings are filed as before (never gated blind).
|
|
479
|
+
fi
|
|
480
|
+
# Contract-declared scope (#3447; rules in the 6B.5 bullet): `deferred -> #N` (follow-up open) and `accepted-risk:` paths become NOTEs, `not-affected`
|
|
481
|
+
# never demotes, and any missing input or unverifiable deferred row leaves nothing demoted (fail toward filing).
|
|
482
|
+
CONTRACT_SCOPE_FILE=""; CONTRACT_OPEN_FILE=""; PR_FILES_FILE=""; CONTRACT_DEMOTED=0
|
|
483
|
+
if [ -n "${MERGE_ISSUE:-}" ]; then
|
|
484
|
+
SCOPE_SCRIPT=""
|
|
485
|
+
[ -n "$TRUSTED_SCRIPT" ] && [ -f "$(dirname "$TRUSTED_SCRIPT")/check-contract-scope.sh" ] && SCOPE_SCRIPT="$(dirname "$TRUSTED_SCRIPT")/check-contract-scope.sh"
|
|
486
|
+
if [ -n "$SCOPE_SCRIPT" ]; then
|
|
487
|
+
_scratch="$WORKDIR"
|
|
488
|
+
CONTRACT_BODY_FILE="$_scratch/contract"; CONTRACT_SCOPE_FILE="$_scratch/contract-scope"; CONTRACT_OPEN_FILE="$_scratch/contract-open"; PR_FILES_FILE="$_scratch/pr-files"
|
|
489
|
+
: > "$CONTRACT_BODY_FILE"; : > "$CONTRACT_SCOPE_FILE"; : > "$CONTRACT_OPEN_FILE"; : > "$PR_FILES_FILE"
|
|
490
|
+
# Only a trusted author's contract counts (an untrusted comment must not suppress findings); the LAST one wins.
|
|
491
|
+
gh api --paginate "repos/${REPO}/issues/${MERGE_ISSUE}/comments" 2>/dev/null \
|
|
492
|
+
| bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:CONTRACT -->' 2>/dev/null | tail -n 1 | jq -r . > "$CONTRACT_BODY_FILE" 2>/dev/null
|
|
493
|
+
if [ -s "$CONTRACT_BODY_FILE" ] \
|
|
494
|
+
&& bash "$SCOPE_SCRIPT" list "$CONTRACT_BODY_FILE" > "$CONTRACT_SCOPE_FILE" 2>/dev/null \
|
|
495
|
+
&& [ -s "$CONTRACT_SCOPE_FILE" ] \
|
|
496
|
+
&& gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$PR_FILES_FILE" 2>/dev/null; then
|
|
497
|
+
# A deferred row counts only if issue N is OPEN, is an issue (not a PR), is not MERGE_ISSUE, and its body
|
|
498
|
+
# carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus the row's path, and is authored by a trusted author
|
|
499
|
+
# (OWNER/MEMBER/COLLABORATOR or Bot; same predicate as trusted-comments.sh; missing fields fail closed).
|
|
500
|
+
# Unverified rows are dropped.
|
|
501
|
+
_verified="$_scratch/contract-verified"; : > "$_verified"
|
|
502
|
+
while IFS="$(printf '\t')" read -r _disp _ipath _n; do
|
|
503
|
+
if [ "$_disp" = "deferred" ]; then
|
|
504
|
+
case "$_n" in ''|*[!0-9]*) continue ;; esac
|
|
505
|
+
[ "$_n" = "${MERGE_ISSUE#\#}" ] && continue
|
|
506
|
+
gh api "repos/${REPO}/issues/$_n" 2>/dev/null \
|
|
507
|
+
| jq -e --arg m "FORGE:DEFERRED_FROM: #${MERGE_ISSUE#\#} -->" --arg p "$_ipath" \
|
|
508
|
+
'.state == "open" and .pull_request == null and ((.body // "") | contains($m) and contains($p)) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
|
|
509
|
+
|| continue
|
|
510
|
+
grep -Fxq -- "$_n" "$CONTRACT_OPEN_FILE" || echo "$_n" >> "$CONTRACT_OPEN_FILE"
|
|
511
|
+
fi
|
|
512
|
+
printf '%s\t%s\t%s\n' "$_disp" "$_ipath" "$_n" >> "$_verified"
|
|
513
|
+
done < "$CONTRACT_SCOPE_FILE"
|
|
514
|
+
mv "$_verified" "$CONTRACT_SCOPE_FILE"
|
|
515
|
+
else
|
|
516
|
+
rm -f "$CONTRACT_SCOPE_FILE" "$CONTRACT_OPEN_FILE" "$PR_FILES_FILE"; CONTRACT_SCOPE_FILE=""; CONTRACT_OPEN_FILE=""; PR_FILES_FILE=""
|
|
517
|
+
fi
|
|
518
|
+
rm -f "$CONTRACT_BODY_FILE"
|
|
519
|
+
fi
|
|
520
|
+
fi
|
|
521
|
+
PRIOR_FILE=""; DIFF_MAP_FILE=""; NOTES_REFLAG=0; OUT_OF_SCOPE=0
|
|
522
|
+
# Cross-head re-flag input (#3641). Fail soft: an unreadable source only shrinks the TSV, never blocks the review.
|
|
523
|
+
# PF_SKIP="<sha or empty>" PF_PRS="<pr numbers>" prior_findings_tsv prints file<TAB>line-start<TAB>line-end<TAB>title<TAB>disposition<TAB>issue# rows
|
|
524
|
+
# (disposition: filed = linked issue OPEN, filed-closed = CLOSED, note = recorded NOTE_DISPOSITION with no issue, unknown = reviewer-body finding whose linkage failed, never demotes a MEDIUM CONFIRMED/LIKELY re-flag).
|
|
525
|
+
# Every comment is read through scripts/trusted-comments.sh. With PF_SKIP set, that head's reviewer bodies are skipped and only NOTE_DISPOSITION records with a different `reviewed_sha=` count.
|
|
526
|
+
pf_split_loc() { # PF_LOC="file[:N[-M]]" -> file<TAB>N<TAB>M (`-` sentinel when a line column is absent: a tab IFS read collapses empty fields)
|
|
527
|
+
local _loc="$PF_LOC" _file _r _a="" _b=""; _file="$_loc"
|
|
528
|
+
case "$_loc" in *:[0-9]*) _r="${_loc##*:}"; case "$_r" in *[!0-9-]*|"") ;; *) _file="${_loc%:*}"; _a="${_r%%-*}"; _b="${_r#*-}"; [ "$_b" != "$_r" ] || _b="$_a" ;; esac ;; esac
|
|
529
|
+
printf '%s\t%s\t%s\n' "$_file" "${_a:--}" "${_b:--}"
|
|
530
|
+
}
|
|
531
|
+
prior_findings_tsv() {
|
|
532
|
+
local pr cj rows issues _id _c _s _loc _t _p _hit _f _a _b _src _dflt
|
|
533
|
+
[ -n "${TRUSTED_SCRIPT:-}" ] || return 0
|
|
534
|
+
rows=$(mktemp "${_pscr}/prior-rows.XXXXXX"); issues=$(mktemp "${_pscr}/prior-issues.XXXXXX")
|
|
535
|
+
for pr in $PF_PRS; do
|
|
536
|
+
case "$pr" in ''|*[!0-9]*) continue ;; esac
|
|
537
|
+
cj=$(gh api --paginate "repos/${REPO}/issues/${pr}/comments" 2>/dev/null) || continue
|
|
538
|
+
[ -n "$cj" ] || continue
|
|
539
|
+
printf '%s' "$cj" | bash "$TRUSTED_SCRIPT" bodies '^<!-- (FORGE:REVIEW-AGENT:[a-z-]+|REVIEW-FINDINGS-SYNTHESIZED-START) -->[\s\S]*(^|\n)Reviewed-SHA: [0-9a-f]{40,64}' 2>/dev/null \
|
|
540
|
+
| jq -r --arg skip "${PF_SKIP:-}" 'select($skip == "" or (test("(^|\\n)Reviewed-SHA: " + $skip + "\\r?(\\n|$)") | not)) | scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null \
|
|
541
|
+
| while IFS='|' read -r _id _c _s _loc _t; do
|
|
542
|
+
[ -n "$_t" ] || continue
|
|
543
|
+
_p=$(PF_LOC="$_loc" pf_split_loc); printf 'F\t%s\t%s\n' "$_p" "$(printf '%s' "$_t" | tr '\t' ' ')"
|
|
544
|
+
done >> "$rows"
|
|
545
|
+
printf '%s' "$cj" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:NOTE_DISPOSITION' 2>/dev/null \
|
|
546
|
+
| jq -r --arg skip "${PF_SKIP:-}" 'select($skip == "" or ((capture("^<!-- FORGE:NOTE_DISPOSITION:[^>]*reviewed_sha=(?<s>[0-9a-f]{40,64})") | .s) as $s | $s != $skip)) | split("\n")[] | select(startswith("- "))' 2>/dev/null \
|
|
547
|
+
| sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p' \
|
|
548
|
+
| while IFS="$(printf '\t')" read -r _loc _t; do
|
|
549
|
+
[ "$_loc" != "?" ] || continue
|
|
550
|
+
_p=$(PF_LOC="$_loc" pf_split_loc); printf 'N\t%s\t%s\n' "$_p" "$_t"
|
|
551
|
+
done >> "$rows"
|
|
552
|
+
gh issue list -R "$REPO" --label review-finding --state all --search "${pr} in:body" --limit 100 --json number,state,title,body 2>/dev/null \
|
|
553
|
+
| jq -c '.[]' >> "$issues" 2>/dev/null || true
|
|
554
|
+
done
|
|
555
|
+
# Linkage: a filed issue whose body names the file and whose title shares >= 3 tokens with the finding (open preferred).
|
|
556
|
+
# Rows carry a leading source column (F = reviewer FINDING marker, N = NOTE_DISPOSITION record), stripped on output. An unlinked F row may
|
|
557
|
+
# have been filed under a reworded title, so it is `unknown` (never demotes MEDIUM CONFIRMED/LIKELY); an unlinked N row is a true `note`.
|
|
558
|
+
sort -u "$rows" | while IFS="$(printf '\t')" read -r _src _f _a _b _t; do
|
|
559
|
+
[ -n "$_f" ] || continue
|
|
560
|
+
[ "$_a" != "-" ] || _a=""; [ "$_b" != "-" ] || _b=""
|
|
561
|
+
case "$_src" in N) _dflt=note ;; *) _dflt=unknown ;; esac
|
|
562
|
+
_hit=$(jq -rs --arg f "$_f" --arg t "$_t" 'def toks: ascii_downcase | [scan("[a-z0-9]{3,}")] | unique;
|
|
563
|
+
($t | toks) as $a | [ .[] | select((.body // "") | contains($f))
|
|
564
|
+
| select((((.title // "") | toks) as $b | [$a[] | select(. as $x | $b | index($x))] | length) >= 3)
|
|
565
|
+
| {n: .number, s: (.state | ascii_downcase)} ]
|
|
566
|
+
| (map(select(.s == "open")) + map(select(.s != "open"))) | .[0] // empty | "\(.s)\t\(.n)"' "$issues" 2>/dev/null) || _hit=""
|
|
567
|
+
case "$_hit" in
|
|
568
|
+
open*) printf '%s\t%s\t%s\t%s\tfiled\t%s\n' "$_f" "$_a" "$_b" "$_t" "${_hit#* }" ;;
|
|
569
|
+
closed*) printf '%s\t%s\t%s\t%s\tfiled-closed\t%s\n' "$_f" "$_a" "$_b" "$_t" "${_hit#* }" ;;
|
|
570
|
+
*) printf '%s\t%s\t%s\t%s\t%s\t\n' "$_f" "$_a" "$_b" "$_t" "$_dflt" ;;
|
|
571
|
+
esac
|
|
572
|
+
done
|
|
573
|
+
rm -f "$rows" "$issues"
|
|
574
|
+
}
|
|
575
|
+
# A staging bundle review (--prior-prs) re-flags against the member PRs' own reviews, with nothing skipped; otherwise against this PR's earlier heads.
|
|
576
|
+
if [ "$BUNDLE" = "true" ]; then PRIOR_ROWS=$(PF_SKIP="" PF_PRS="$PRIOR_PRS" prior_findings_tsv); else PRIOR_ROWS=$(PF_SKIP="$REVIEW_SHA" PF_PRS="$PR_NUMBER" prior_findings_tsv); fi
|
|
577
|
+
if [ -n "$PRIOR_ROWS" ]; then
|
|
578
|
+
PRIOR_FILE="$WORKDIR/prior-findings"; printf '%s\n' "$PRIOR_ROWS" > "$PRIOR_FILE"
|
|
579
|
+
# Best-effort hunk map from the most recent prior reviewed head to this one, so a moved line still overlaps. Any failure leaves the unshifted ranges.
|
|
580
|
+
PRIOR_SHA=""
|
|
581
|
+
[ "$BUNDLE" = "true" ] || PRIOR_SHA=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
|
|
582
|
+
| bash "$TRUSTED_SCRIPT" bodies '^<!-- (FORGE:REVIEW-AGENT:[a-z-]+|REVIEW-FINDINGS-SYNTHESIZED-START) -->[\s\S]*(^|\n)Reviewed-SHA: [0-9a-f]{40,64}' 2>/dev/null \
|
|
583
|
+
| jq -r 'scan("(^|\\n)Reviewed-SHA: ([0-9a-f]{40,64})") | .[1]' 2>/dev/null | grep -vx "$REVIEW_SHA" | tail -n 1)
|
|
584
|
+
_top=$(git rev-parse --show-toplevel 2>/dev/null) || _top=""
|
|
585
|
+
if [ -n "$PRIOR_SHA" ] && [ -n "$_top" ]; then
|
|
586
|
+
DIFF_MAP_FILE="$WORKDIR/prior-diffmap"
|
|
587
|
+
{ git -C "$_top" diff -U0 --no-color --no-renames "$PRIOR_SHA" "$REVIEW_SHA" 2>/dev/null || true; } | while IFS= read -r _l; do
|
|
588
|
+
case "$_l" in
|
|
589
|
+
"+++ b/"*) _f="${_l#+++ b/}" ;;
|
|
590
|
+
"@@ "*) _h="${_l#@@ -}"; _o="${_h%% *}"; _n="${_h#* +}"; _n="${_n%% *}"
|
|
591
|
+
case "$_o" in *,*) _oc="${_o#*,}" ;; *) _oc=1 ;; esac; case "$_n" in *,*) _nc="${_n#*,}" ;; *) _nc=1 ;; esac
|
|
592
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$_f" "${_o%%,*}" "$_oc" "${_n%%,*}" "$_nc" ;;
|
|
593
|
+
esac
|
|
594
|
+
done > "$DIFF_MAP_FILE" || : > "$DIFF_MAP_FILE"
|
|
595
|
+
fi
|
|
596
|
+
fi
|
|
597
|
+
|
|
598
|
+
# Per deduped finding: the classifier is called as is (§6B.5). Pre-existing findings are classified without --inpr-diff (a defect the PR did
|
|
599
|
+
# not introduce is never an in-PR fix); no-scenario demotions are NOTEs and are not classified.
|
|
600
|
+
CAP_ROWS_FILE="$WORKDIR/cap-rows"; : > "$CAP_ROWS_FILE"
|
|
601
|
+
DISP_FILE="$WORKDIR/disp.tsv"; : > "$DISP_FILE"
|
|
602
|
+
INPR_FIX_COUNT=0; INPR_FIX_IDS=""; INPR_MAX_RANK=9; INPR_MAX_SEV=""; INPR_FIX_LIST_FILE="$WORKDIR/inpr-fix-list"; : > "$INPR_FIX_LIST_FILE"
|
|
603
|
+
TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0
|
|
604
|
+
while [ "$I" -lt "$TOTAL" ]; do
|
|
605
|
+
F_ID=$(fj .id); F_SEV=$(fj .severity); F_CONF=$(fj .confidence); F_AGENT=$(fj .agent); F_FILE=$(fj .file); F_TITLE=$(fj .title)
|
|
606
|
+
F_ROUTE=$(fj .route); F_FIX=$(fj .fix_efficacy); F_L1=$(fj .line_start); F_L2=$(fj .line_end); F_LOC=$(fj .loc)
|
|
607
|
+
if [ "$F_ROUTE" = "demoted-no-scenario" ]; then printf '%s\t%s\n' "$I" "NOTE no-scenario" >> "$DISP_FILE"; I=$((I + 1)); continue; fi
|
|
608
|
+
FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r ".findings[$I].text // \"\"" "$IN_FILE" > "$FINDING_TEXT_FILE" 2>/dev/null
|
|
609
|
+
set -- --severity "$F_SEV" --confidence "$F_CONF" --agent "$F_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE"
|
|
610
|
+
[ -n "$F_FILE" ] && set -- "$@" --file "$F_FILE"
|
|
611
|
+
[ -n "$INPR_DIFF_FILE" ] && [ "$F_ROUTE" != "pre-existing" ] && set -- "$@" --inpr-diff "$INPR_DIFF_FILE"
|
|
612
|
+
[ -n "$CONTRACT_SCOPE_FILE" ] && set -- "$@" --contract-scope "$CONTRACT_SCOPE_FILE" --contract-open "$CONTRACT_OPEN_FILE" --pr-files "$PR_FILES_FILE"
|
|
613
|
+
[ -n "$MERGE_ISSUE" ] && set -- "$@" --fixes-issue "$MERGE_ISSUE"
|
|
614
|
+
case "$F_FIX" in fails|regresses) set -- "$@" --fix-efficacy "$F_FIX" ;; esac
|
|
615
|
+
[ -n "$PRIOR_FILE" ] && { set -- "$@" --prior-findings "$PRIOR_FILE" --title "$F_TITLE"; [ -n "$DIFF_MAP_FILE" ] && set -- "$@" --diff-map "$DIFF_MAP_FILE"; }
|
|
616
|
+
[ -n "$F_L1" ] && set -- "$@" --line-start "$F_L1" --line-end "${F_L2:-$F_L1}"
|
|
617
|
+
DISPOSITION=$(bash "$CLASSIFY_SCRIPT" "$@" 2>/dev/null) || DISPOSITION=""
|
|
618
|
+
DISPOSITION=$(printf '%s\n' "$DISPOSITION" | head -n 1)
|
|
619
|
+
# A classifier runtime failure fails toward filing, like the classifier itself (the resolution failure above is what blocks).
|
|
620
|
+
case "$DISPOSITION" in ISSUE*|NOTE*|INPR_FIX*) ;; *) DISPOSITION="ISSUE classifier-error" ;; esac
|
|
621
|
+
# Staging bundle scope rule (--scope-files; the two SCOPE_RULE lines are evaluated by scripts/review-pr-staging-scope.test.sh): only a finding whose
|
|
622
|
+
# normalised severity is MEDIUM, LOW or INFO may be demoted, and only when it cites a file and that file is outside the review scope.
|
|
623
|
+
# The classifier's safety exemption and an existing re-flag are never second-guessed, and the demotion is a listed NOTE, never a drop.
|
|
624
|
+
SEV=$(printf '%s' "$F_SEV" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]') # SCOPE_RULE
|
|
625
|
+
case "$SEV" in MEDIUM|LOW|INFO) OOS_DEMOTE=1 ;; *) OOS_DEMOTE=0 ;; esac # SCOPE_RULE
|
|
626
|
+
case "$DISPOSITION" in *safety-exemption*|"NOTE reflag"*|INPR_FIX*) OOS_DEMOTE=0 ;; esac
|
|
627
|
+
if [ -n "$SCOPE_FILES" ] && [ -r "$SCOPE_FILES" ] && [ "$OOS_DEMOTE" = "1" ] && [ -n "$F_FILE" ] && ! grep -qxF -- "$F_FILE" "$SCOPE_FILES"; then
|
|
628
|
+
DISPOSITION="NOTE out_of_scope"; OUT_OF_SCOPE=$((OUT_OF_SCOPE + 1))
|
|
629
|
+
fi
|
|
630
|
+
case "$DISPOSITION" in
|
|
631
|
+
"NOTE contract-deferred #"*)
|
|
632
|
+
_dn="${DISPOSITION##*#}"
|
|
633
|
+
case "$_dn" in ''|*[!0-9]*) ;; *)
|
|
634
|
+
[ "$DRY_RUN" = "true" ] || gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
|
|
635
|
+
;; esac
|
|
636
|
+
CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
|
|
637
|
+
"NOTE contract-accepted-risk"*) CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
|
|
638
|
+
"NOTE reflag-"*) NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
|
|
639
|
+
INPR_FIX*)
|
|
640
|
+
INPR_FIX_COUNT=$((INPR_FIX_COUNT + 1)); INPR_FIX_IDS="${INPR_FIX_IDS:+$INPR_FIX_IDS,}${F_ID}"
|
|
641
|
+
_rk=$(sev_rank "$F_SEV"); if [ "$_rk" -lt "$INPR_MAX_RANK" ]; then INPR_MAX_RANK="$_rk"; INPR_MAX_SEV=$(upper "$F_SEV"); fi
|
|
642
|
+
_fix=$(grep -iE '(suggested fix|\*\*fix\*\*)[*]*:' "$FINDING_TEXT_FILE" 2>/dev/null | head -n 1 | sed -E 's/^.*[Ff]ix[*]*:[[:space:]]*//')
|
|
643
|
+
printf -- '- %s %s: %s%s\n' "$F_ID" "$F_LOC" "$(printf '%s' "$F_TITLE" | tr '\t\n' ' ')" "${_fix:+ (fix: $_fix)}" >> "$INPR_FIX_LIST_FILE" ;;
|
|
644
|
+
esac
|
|
645
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$I" "$F_SEV" "$F_CONF" "$DISPOSITION" "$F_AGENT" > "$WORKDIR/row.$I"
|
|
646
|
+
# Pre-existing findings are bounded separately (below) and never consume the per-review cap.
|
|
647
|
+
[ "$F_ROUTE" = "pre-existing" ] || cat "$WORKDIR/row.$I" >> "$CAP_ROWS_FILE"
|
|
648
|
+
printf '%s\t%s\n' "$I" "$DISPOSITION" >> "$DISP_FILE"
|
|
649
|
+
I=$((I + 1))
|
|
650
|
+
done
|
|
651
|
+
|
|
652
|
+
# Per-review cap pass (#3642, MANDATORY): classify EVERY finding first; file nothing until the cap has run.
|
|
653
|
+
CAP_OUT=$(bash "$CAP_SCRIPT" --lineage "$FINDING_LINEAGE" ${MAX_FILED_FINDINGS:+--cap "$MAX_FILED_FINDINGS"} --input "$CAP_ROWS_FILE" 2>/dev/null) || CAP_OUT=""
|
|
654
|
+
NOTES_CAPPED=$(printf '%s\n' "$CAP_OUT" | sed -n 's/^CAPPED=//p' | tail -n 1); NOTES_CAPPED="${NOTES_CAPPED:-0}"
|
|
655
|
+
CAPPED_IDS=$(printf '%s\n' "$CAP_OUT" | awk -F'\t' 'NF >= 2 && $2 == "NOTE capped" { print $1 }') # allowlist:positional-arg
|
|
656
|
+
# An id in CAPPED_IDS is a NOTE (`capped`, list it, never filed); every other row keeps the classifier disposition. A helper runtime failure
|
|
657
|
+
# (empty CAP_OUT) keeps the original dispositions and NOTES_CAPPED=0 (fail toward filing; the cap is a damper, not a gate).
|
|
658
|
+
[ -n "$CAP_OUT" ] || NOTES_CAPPED=0
|
|
659
|
+
case "$NOTES_CAPPED" in ''|*[!0-9]*) NOTES_CAPPED=0 ;; esac
|
|
660
|
+
for _cid in $CAPPED_IDS; do
|
|
661
|
+
case "$_cid" in ''|*[!0-9]*) continue ;; esac
|
|
662
|
+
awk -F'\t' -v k="$_cid" 'BEGIN{OFS="\t"} $1 == k { $2 = "NOTE capped" } { print }' "$DISP_FILE" > "$DISP_FILE.n" && mv "$DISP_FILE.n" "$DISP_FILE"
|
|
663
|
+
done
|
|
664
|
+
|
|
665
|
+
# Assemble the classified JSON: disposition and class per finding.
|
|
666
|
+
jq --rawfile d "$DISP_FILE" '
|
|
667
|
+
($d | split("\n") | map(select(length > 0) | split("\t") | {(.[0]): .[1]}) | add // {}) as $m
|
|
668
|
+
| .findings |= (to_entries | map(.value + {disposition: ($m[.key | tostring] // "")} | . + {class: (.disposition | split(" ")[0])}) )' "$IN_FILE" > "$OUT_FILE.tmp" \
|
|
669
|
+
|| { echo "review-finalize: cannot assemble classified JSON" >&2; exit 2; }
|
|
670
|
+
# Pre-existing route: at most 3 per review, highest severity first, among those the classifier called ISSUE; the rest are listed only.
|
|
671
|
+
PRE_FILED=0; PRE_IDX=$(jq -r '.findings | to_entries | map(select(.value.route == "pre-existing" and .value.class == "ISSUE")) | sort_by([(.value.severity | ascii_upcase | if . == "CRITICAL" then 0 elif . == "HIGH" then 1 elif . == "MEDIUM" then 2 else 3 end), .key]) | .[].key' "$OUT_FILE.tmp")
|
|
672
|
+
PRE_KEEP=$(printf '%s\n' "$PRE_IDX" | sed -n '1,3p' | tr '\n' ' ')
|
|
673
|
+
PRE_FILED=$(printf '%s\n' "$PRE_IDX" | sed -n '1,3p' | grep -c . || true)
|
|
674
|
+
jq --arg keep " ${PRE_KEEP}" '
|
|
675
|
+
.findings = [ .findings | to_entries[] | .key as $k | .value
|
|
676
|
+
| if .route == "pre-existing" and .class == "ISSUE" then
|
|
677
|
+
(if ($keep | contains(" " + ($k | tostring) + " ")) then . + {route: "file-pre-existing"}
|
|
678
|
+
else . + {disposition: "NOTE preexisting-over-cap", class: "NOTE"} end)
|
|
679
|
+
else . end ]' "$OUT_FILE.tmp" > "$OUT_FILE" || { echo "review-finalize: cannot assemble classified JSON" >&2; exit 2; }
|
|
680
|
+
rm -f "$OUT_FILE.tmp"
|
|
681
|
+
|
|
682
|
+
# Counters (after the cap pass). Contract-demoted, re-flagged and capped NOTEs are counted only in their own counters.
|
|
683
|
+
FINDINGS_FILED=$(jq '[.findings[] | select(.class == "ISSUE" and .route != "file-pre-existing" and .route != "pre-existing")] | length' "$OUT_FILE")
|
|
684
|
+
PLAIN_NOTES=$(jq '[.findings[] | select(.class == "NOTE" and .route != "pre-existing" and (.disposition | test("^NOTE (contract-|reflag-|capped)") | not))] | length' "$OUT_FILE")
|
|
685
|
+
NOTES_LISTED=$((PLAIN_NOTES - NOTES_FIXED - NOTES_DROPPED)); [ "$NOTES_LISTED" -ge 0 ] || NOTES_LISTED=0
|
|
686
|
+
N_STALE=$(jq -r '.counts.findings_dropped_stale // 0' "$OUT_FILE"); N_PRE=$(jq -r '.counts.findings_preexisting // 0' "$OUT_FILE"); N_NOSCEN=$(jq -r '.counts.notes_demoted_no_scenario // 0' "$OUT_FILE")
|
|
687
|
+
N_EXTRACTED=$(jq -r '.counts.findings_extracted // (.findings | length)' "$OUT_FILE")
|
|
688
|
+
CLASSIFIER_MODE=script # the guard above blocks the review when the classifier is unresolvable, so this is always script
|
|
689
|
+
|
|
690
|
+
# NOTE list: one line per NOTE `- <id> <file:line|?> [<CONFIDENCE>] <title>: <disposition>` (the shape prior_findings_tsv parses on a later head;
|
|
691
|
+
# the disposition text carries no colon so the last `: ` always splits title from disposition).
|
|
692
|
+
NOTE_LIST_FILE="$OUT_DIR/note-list.txt"; : > "$NOTE_LIST_FILE"
|
|
693
|
+
jq -r '.findings[] | select(.class == "NOTE" and .route != "pre-existing") |
|
|
694
|
+
[.id, (if (.file // "") == "" then "?" else .loc end), ((.confidence // "") | ascii_upcase | gsub("[^A-Z]"; "") | if . == "" then "?" else . end),
|
|
695
|
+
((.title // "") | gsub("[\t\r\n]"; " ")),
|
|
696
|
+
(.disposition | sub("^NOTE "; "")
|
|
697
|
+
| if test("^reflag-filed #") then "tracked on #" + (capture("#(?<n>[0-9]+)").n // "?")
|
|
698
|
+
elif test("^reflag-note") then "already noted on an earlier head"
|
|
699
|
+
elif test("^contract-deferred #") then "tracked on #" + (capture("#(?<n>[0-9]+)").n // "?")
|
|
700
|
+
elif test("^contract-accepted-risk") then "accepted-risk"
|
|
701
|
+
elif . == "capped" then "capped - over review.max_filed_findings"
|
|
702
|
+
else gsub(":"; " ") end)]
|
|
703
|
+
| "- \(.[0]) \(.[1] | gsub(" "; "_")) [\(.[2])] \(.[3]): \(.[4])"' "$OUT_FILE" > "$NOTE_LIST_FILE" 2>/dev/null
|
|
704
|
+
|
|
705
|
+
# Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding): Phase 8 refuses to auto-merge a PR that has FINDING:
|
|
706
|
+
# markers but no FORGE:NOTE_DISPOSITION comment. `reviewed_sha` is the head this record covers (a later review of ANOTHER head reads it as a prior list).
|
|
707
|
+
BUNDLE_MARKER=""; if [ "$BUNDLE" = "true" ]; then _sm=full; [ -z "$SCOPE_FILES" ] || _sm=scoped; BUNDLE_MARKER=" out_of_scope=${OUT_OF_SCOPE} scope=${_sm}"; fi
|
|
708
|
+
NOTE_DISPOSITION_BODY="<!-- FORGE:NOTE_DISPOSITION: notes_fixed=${NOTES_FIXED:-0} notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} inpr_fix=${INPR_FIX_COUNT:-0} contract_demoted=${CONTRACT_DEMOTED:-0} findings_dropped_stale=${N_STALE:-0} findings_preexisting=${N_PRE:-0} notes_demoted_no_scenario=${N_NOSCEN:-0} lineage=${FINDING_LINEAGE} classifier=${CLASSIFIER_MODE} notes_reflag=${NOTES_REFLAG:-0} notes_capped=${NOTES_CAPPED:-0} reviewed_sha=${REVIEW_SHA}${BUNDLE_MARKER} -->
|
|
709
|
+
$(cat "$NOTE_LIST_FILE" 2>/dev/null)"
|
|
710
|
+
printf '%s\n' "$NOTE_DISPOSITION_BODY" > "$OUT_DIR/note-disposition.md"
|
|
711
|
+
POSTED_DISPOSITION=false; POSTED_INPR=false
|
|
712
|
+
if [ "$N_EXTRACTED" -gt 0 ] && [ "$DRY_RUN" != "true" ]; then
|
|
713
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "$NOTE_DISPOSITION_BODY" >/dev/null || blocked "could not post FORGE:NOTE_DISPOSITION (fail closed)" # allowlist:check-command-side-effects
|
|
714
|
+
POSTED_DISPOSITION=true
|
|
715
|
+
fi
|
|
716
|
+
# In-PR fix request (6B.6), after the disposition marker. Phase 8 then refuses to merge this head (`in-pr fix required`).
|
|
717
|
+
if [ "$INPR_FIX_COUNT" -gt 0 ] && [ "$DRY_RUN" != "true" ]; then
|
|
718
|
+
INPR_HEAD=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq .headRefOid 2>/dev/null); is_sha "$INPR_HEAD" || INPR_HEAD="$REVIEW_SHA"
|
|
719
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "<!-- FORGE:INPR_FIX: round=1 head=${INPR_HEAD} max_severity=${INPR_MAX_SEV:-MEDIUM} findings=${INPR_FIX_IDS} -->
|
|
720
|
+
In-PR fix requested before merge (CONFIRMED findings in files this PR changed). One fix round; anything still present on the next review is filed as an issue.
|
|
721
|
+
$(cat "$INPR_FIX_LIST_FILE" 2>/dev/null)" >/dev/null || blocked "could not post FORGE:INPR_FIX (fail closed)" # allowlist:check-command-side-effects
|
|
722
|
+
POSTED_INPR=true
|
|
723
|
+
fi
|
|
724
|
+
echo "CLASSIFIED_OUT=$OUT_FILE"
|
|
725
|
+
echo "NOTE_LIST=$NOTE_LIST_FILE"
|
|
726
|
+
echo "FINDING_LINEAGE=$FINDING_LINEAGE"
|
|
727
|
+
echo "FINDINGS_FILED=$FINDINGS_FILED"
|
|
728
|
+
echo "INPR_FIX=$INPR_FIX_COUNT"
|
|
729
|
+
echo "CONTRACT_DEMOTED=$CONTRACT_DEMOTED"
|
|
730
|
+
echo "NOTES_REFLAG=$NOTES_REFLAG"
|
|
731
|
+
echo "NOTES_CAPPED=$NOTES_CAPPED"
|
|
732
|
+
echo "NOTES_LISTED=$NOTES_LISTED"
|
|
733
|
+
echo "PREEXISTING_FILED=$PRE_FILED"
|
|
734
|
+
echo "NOTE_DISPOSITION_POSTED=$POSTED_DISPOSITION"
|
|
735
|
+
echo "INPR_FIX_POSTED=$POSTED_INPR"
|
|
736
|
+
exit 0
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
# ======================================================================================================================
|
|
740
|
+
# file-prepare: 6C up to (not including) the issue-creation call
|
|
741
|
+
# ======================================================================================================================
|
|
742
|
+
do_file_prepare() {
|
|
743
|
+
IN_FILE="${IN_FILE:-$OUT_DIR/classified.json}"; OUT_FILE="${OUT_FILE:-$OUT_DIR/worklist.json}"
|
|
744
|
+
need_findings
|
|
745
|
+
if [ "$DRY_RUN" != "true" ]; then
|
|
746
|
+
# Colors match the canonical ForgeDock label manifest (bin/labels.json).
|
|
747
|
+
gh label create "review-finding" --color "D93F0B" --description "Defect or improvement found during automated PR review. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
|
|
748
|
+
gh label create "needs-validation" --color "FBCA04" --description "Review finding awaiting human validation. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
|
|
749
|
+
gh label create "validated" --color "0E8A16" --description "Review finding confirmed as a real issue. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
|
|
750
|
+
gh label create "false-positive" --color "CCCCCC" --description "Review finding dismissed as a false positive. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
|
|
751
|
+
gh label create "pre-existing" --color "BFD4F2" --description "Defect noticed during PR review that the PR did not introduce. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
|
|
752
|
+
fi
|
|
753
|
+
BASE_BRANCH="$CTX_BASE_BRANCH"
|
|
754
|
+
[ -n "$BASE_BRANCH" ] || BASE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
755
|
+
# scripts/derive-finding-milestone.sh is the single source of truth for milestone derivation (also called by review-pr-staging.md, so
|
|
756
|
+
# the specs cannot drift). Unresolved helper: empty milestone (non-blocking), never a cwd-relative fallback.
|
|
757
|
+
DERIVE_MILESTONE_SCRIPT=$(forge_resolve derive-finding-milestone.sh)
|
|
758
|
+
MILESTONE_TITLE=""
|
|
759
|
+
[ -n "$DERIVE_MILESTONE_SCRIPT" ] && MILESTONE_TITLE=$(bash "$DERIVE_MILESTONE_SCRIPT" "${PR_NUMBER}" -R "${REPO}" 2>/dev/null)
|
|
760
|
+
DEDUP_SCRIPT=$(forge_resolve issue-dedup.sh)
|
|
761
|
+
PRIORITY_SCRIPT=$(forge_resolve severity-to-priority.sh)
|
|
762
|
+
# Existing review-finding issues, read once (never per finding). An unreadable list means nothing matches (file normally).
|
|
763
|
+
OPEN_JSON=$(gh issue list -R "$REPO" --state open --label "review-finding" --limit 100 --json number,title,body 2>/dev/null) || OPEN_JSON="[]"
|
|
764
|
+
CLOSED_JSON=$(gh issue list -R "$REPO" --state closed --label "review-finding" --limit 100 --json number,title,body 2>/dev/null) || CLOSED_JSON="[]"
|
|
765
|
+
printf '%s' "$OPEN_JSON" | jq -e 'type == "array"' >/dev/null 2>&1 || OPEN_JSON="[]"
|
|
766
|
+
printf '%s' "$CLOSED_JSON" | jq -e 'type == "array"' >/dev/null 2>&1 || CLOSED_JSON="[]"
|
|
767
|
+
SELF="${MERGE_ISSUE:-0}"; [[ "$SELF" =~ ^[0-9]+$ ]] || SELF=0
|
|
768
|
+
|
|
769
|
+
# first number of the open (or closed) issue whose body cites FILE within +-5 lines of LINE (a title rule applies to open issues only)
|
|
770
|
+
line_match() { # line_match <json> <file> <line> <title> <title-rule 0|1>
|
|
771
|
+
printf '%s' "$1" | jq -r --arg file "$2" --argjson ln "$3" --arg title "$4" --argjson tr "$5" --argjson self "$SELF" '
|
|
772
|
+
def words: ascii_downcase | split(" ") | map(select(length > 3));
|
|
773
|
+
.[] | select(.number != $self) | select((.body // "") | contains($file))
|
|
774
|
+
| ([(.body | split($file + ":") | .[1:][] | capture("^(?<l>[0-9]+)")? | .l | tonumber)] | .[0] // 0) as $el
|
|
775
|
+
| if ($ln > 0 and $el >= ($ln - 5) and $el <= ($ln + 5)) then .number
|
|
776
|
+
elif ($tr == 1 and (($title | words) as $a | (.title | words) as $b | ([$a[] | select(. as $w | any($b[]; . == $w))] | unique | length) >= 3)) then .number
|
|
777
|
+
else empty end' 2>/dev/null | head -1
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
OUT_ITEMS="$WORKDIR/items.jsonl"; : > "$OUT_ITEMS"
|
|
781
|
+
TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0; N_FILE=0
|
|
782
|
+
while [ "$I" -lt "$TOTAL" ]; do
|
|
783
|
+
F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
|
|
784
|
+
case "$F_CLASS:$F_ROUTE" in ISSUE:file|ISSUE:file-pre-existing) ;; *) I=$((I + 1)); continue ;; esac
|
|
785
|
+
F_ID=$(fj .id); F_FILE=$(fj .file); F_TITLE=$(fj .title); F_SEV=$(fj .severity); F_AGENT=$(fj .agent); F_LINE=$(fj .line_start)
|
|
786
|
+
case "$F_LINE" in ''|*[!0-9]*) F_LINE=0 ;; esac
|
|
787
|
+
ACTION="file"; DEDUP_OF=""; REGRESSION=""; NOTE=""; CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""; RECURRENCE=""
|
|
788
|
+
# Title (defense in depth: strip command substitution so the raw title stays readable if it round-trips through an eval-based consumer).
|
|
789
|
+
ISSUE_TITLE="fix: ${F_TITLE} (review finding — PR #${PR_NUMBER})"
|
|
790
|
+
ISSUE_TITLE=$(printf '%s' "$ISSUE_TITLE" | tr '\t\n`' " '" | sed 's/\$(/$ (/g')
|
|
791
|
+
# Step 0: deterministic title dedup (scripts/issue-dedup.sh; also excludes the issue this PR fixes: it is about to be closed, so a
|
|
792
|
+
# finding matched to it is tracked nowhere). Unresolved helper: "not deduped"; NEVER exit 1 (which means "duplicate, skip").
|
|
793
|
+
if [ -n "$DEDUP_SCRIPT" ]; then
|
|
794
|
+
DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "$ISSUE_TITLE" "-R $REPO" ${MERGE_ISSUE:+--exclude "$MERGE_ISSUE"} 2>&1); DEDUP_EXIT=$?
|
|
795
|
+
else
|
|
796
|
+
DEDUP_RESULT=""; DEDUP_EXIT=0
|
|
797
|
+
fi
|
|
798
|
+
if [ "$DEDUP_EXIT" -eq 1 ]; then
|
|
799
|
+
ACTION="skip-dedup"; NOTE="$DEDUP_RESULT"; DEDUP_OF=$(printf '%s' "$DEDUP_RESULT" | sed -n 's/[^0-9]*\([0-9][0-9]*\).*/\1/p' | head -1)
|
|
800
|
+
echo "DEDUP: Skipping — $DEDUP_RESULT"
|
|
801
|
+
elif [ "$DEDUP_EXIT" -eq 2 ]; then
|
|
802
|
+
ACTION="skip-usage-error"; NOTE="$DEDUP_RESULT"
|
|
803
|
+
echo "DEDUP: Usage error — $DEDUP_RESULT"
|
|
804
|
+
else
|
|
805
|
+
# Line-range / title dedup: +-5 tolerance covers off-by-one from upstream insertions; titles are compared without the
|
|
806
|
+
# constant "(review finding — PR #N)" suffix, which every review-finding title shares.
|
|
807
|
+
EXISTING=""; [ -n "$F_FILE" ] && EXISTING=$(line_match "$OPEN_JSON" "$F_FILE" "$F_LINE" "$F_TITLE" 1)
|
|
808
|
+
if [ -n "$EXISTING" ]; then
|
|
809
|
+
ACTION="skip-dedup"; DEDUP_OF="$EXISTING"
|
|
810
|
+
echo "DEDUP: Skipping — open issue #${EXISTING} already covers ${F_FILE}:${F_LINE} (range/title match)"
|
|
811
|
+
else
|
|
812
|
+
# A CLOSED review-finding at the same location +-5 lines is a regression: create with a warning and elevate to priority:P1.
|
|
813
|
+
[ -n "$F_FILE" ] && REGRESSION=$(line_match "$CLOSED_JSON" "$F_FILE" "$F_LINE" "$F_TITLE" 0)
|
|
814
|
+
[ -z "$REGRESSION" ] || echo "REGRESSION: Previously fixed in #${REGRESSION} — elevating priority"
|
|
815
|
+
fi
|
|
816
|
+
fi
|
|
817
|
+
# priority:* is a function of Severity only (scripts/severity-to-priority.sh), never of Confidence. Unresolved or failing helper: skip
|
|
818
|
+
# the finding (never defaulted to a label).
|
|
819
|
+
FINDING_PRIORITY=""
|
|
820
|
+
if [ -n "$PRIORITY_SCRIPT" ]; then FINDING_PRIORITY=$(bash "$PRIORITY_SCRIPT" "$F_SEV" 2>/dev/null); PRIORITY_EXIT=$?; else PRIORITY_EXIT=127; fi
|
|
821
|
+
if [ "$PRIORITY_EXIT" -ne 0 ] || [ -z "$FINDING_PRIORITY" ]; then
|
|
822
|
+
[ "$ACTION" = "file" ] && { ACTION="skip-priority"; NOTE="severity-to-priority.sh failed (exit $PRIORITY_EXIT) for severity '$F_SEV'"; }
|
|
823
|
+
echo "PRIORITY: severity-to-priority.sh failed (exit $PRIORITY_EXIT) for severity '$F_SEV' — skipping finding issue creation"
|
|
824
|
+
FINDING_PRIORITY=""
|
|
825
|
+
fi
|
|
826
|
+
[ -z "$REGRESSION" ] || [ -z "$FINDING_PRIORITY" ] || FINDING_PRIORITY="priority:P1"
|
|
827
|
+
# Pattern recurrence (needs the FORGE:PATTERN slug the caller wrote for the finding, passed in --patterns). Exemptions are decided FIRST.
|
|
828
|
+
PATTERN_SLUG=""
|
|
829
|
+
[ -n "$PATTERNS" ] && [ -r "$PATTERNS" ] && PATTERN_SLUG=$(jq -r --arg id "$F_ID" '.[$id] // empty' "$PATTERNS" 2>/dev/null)
|
|
830
|
+
if [ "$ACTION" != "file" ]; then RECURRENCE="not-applicable"
|
|
831
|
+
elif [ -z "$PATTERN_SLUG" ]; then RECURRENCE="skipped - no pattern slug supplied"
|
|
832
|
+
else
|
|
833
|
+
FINDING_EXEMPT="false"
|
|
834
|
+
case "$(upper "$F_SEV")" in HIGH|CRITICAL) FINDING_EXEMPT="true" ;; esac
|
|
835
|
+
case "$(printf '%s' "$F_AGENT" | tr '[:upper:]' '[:lower:]')" in *security*) FINDING_EXEMPT="true" ;; esac
|
|
836
|
+
[ -z "$REGRESSION" ] || FINDING_EXEMPT="true"
|
|
837
|
+
case "$FINDING_PRIORITY" in priority:P0|priority:P1) FINDING_EXEMPT="true" ;; esac
|
|
838
|
+
if [ "$FINDING_EXEMPT" = "true" ]; then
|
|
839
|
+
RECURRENCE="exempt (severity/category/regression) - filing normally"
|
|
840
|
+
elif ! [[ "$PATTERN_SLUG" =~ ^[a-z0-9-]+$ ]]; then
|
|
841
|
+
RECURRENCE="skipped - invalid slug, filing normally"
|
|
842
|
+
else
|
|
843
|
+
# Subsystem = the directory of the finding's primary file, truncated to its first two segments.
|
|
844
|
+
SUBSYSTEM=$(dirname -- "$F_FILE" | cut -d/ -f1-2)
|
|
845
|
+
if [ "$SUBSYSTEM" = "." ] || ! [[ "$SUBSYSTEM" =~ ^[A-Za-z0-9._/-]+$ ]]; then
|
|
846
|
+
RECURRENCE="skipped - no valid subsystem, filing normally"; SUBSYSTEM=""
|
|
847
|
+
else
|
|
848
|
+
# Narrow server-side by the exact slug tag (the slug is validated, so safe in --search), then filter in jq. Excludes class
|
|
849
|
+
# issues, false-positive findings and issues closed as not planned. Fail closed on lookup errors: file normally.
|
|
850
|
+
PRIOR=$(gh issue list -R "$REPO" --state all --label review-finding --limit 200 --search "FORGE:PATTERN: ${PATTERN_SLUG} in:body" \
|
|
851
|
+
--json number,body,labels,stateReason 2>/dev/null) || PRIOR="__LOOKUP_FAILED__"
|
|
852
|
+
if [ "$PRIOR" = "__LOOKUP_FAILED__" ] || ! printf '%s' "$PRIOR" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
|
853
|
+
RECURRENCE="lookup failed - filing as a normal instance issue"
|
|
854
|
+
else
|
|
855
|
+
RECURRENCE="checked"
|
|
856
|
+
PRIOR_NUMS=$(printf '%s' "$PRIOR" | jq -r --arg slug "$PATTERN_SLUG" --arg sub "$SUBSYSTEM" --argjson self "$SELF" '
|
|
857
|
+
($sub | gsub("\\."; "\\.")) as $esc
|
|
858
|
+
| [.[] | select(.number != $self)
|
|
859
|
+
| select(.stateReason != "NOT_PLANNED")
|
|
860
|
+
| select((.labels | map(.name)) as $l | (($l | index("pattern-class")) or ($l | index("false-positive"))) | not)
|
|
861
|
+
| select(.body | contains("<!-- FORGE:PATTERN: " + $slug + " -->"))
|
|
862
|
+
| select(.body | test("(^|[^A-Za-z0-9._-])" + $esc + "/"))
|
|
863
|
+
| .number] | join(" ")')
|
|
864
|
+
[ -z "$PRIOR_NUMS" ] || { CONSOLIDATE="true"; echo "RECURRENCE: $PATTERN_SLUG seen before in $SUBSYSTEM (#${PRIOR_NUMS// /, #}) - consolidating"; }
|
|
865
|
+
fi
|
|
866
|
+
fi
|
|
867
|
+
fi
|
|
868
|
+
fi
|
|
869
|
+
# P3 batchable annotation: a P3 finding off security/billing paths may be grouped with other P3s by the orchestrator.
|
|
870
|
+
BATCHABLE="false"
|
|
871
|
+
if [ "$FINDING_PRIORITY" = "priority:P3" ]; then
|
|
872
|
+
IS_SECURITY_PATH=0; IS_BILLING_PATH=0
|
|
873
|
+
echo "$F_FILE" | grep -qiE 'security|billing|payment|stripe|charge|invoice' && IS_SECURITY_PATH=1
|
|
874
|
+
echo "$F_TITLE" | grep -qiE 'security|billing|payment|stripe|charge|invoice' && IS_BILLING_PATH=1
|
|
875
|
+
if [ "$IS_SECURITY_PATH" -eq 0 ] && [ "$IS_BILLING_PATH" -eq 0 ]; then BATCHABLE="true"; fi
|
|
876
|
+
fi
|
|
877
|
+
LABELS='["review-finding","needs-validation"]'
|
|
878
|
+
[ "$F_ROUTE" != "file-pre-existing" ] || LABELS='["pre-existing"]'
|
|
879
|
+
[ -z "$FINDING_PRIORITY" ] || LABELS=$(printf '%s' "$LABELS" | jq -c --arg p "$FINDING_PRIORITY" '. + [$p]')
|
|
880
|
+
[ "$ACTION" != "file" ] || N_FILE=$((N_FILE + 1))
|
|
881
|
+
jq -n -c --arg id "$F_ID" --arg action "$ACTION" --arg title "$ISSUE_TITLE" --arg file "$F_FILE" --arg line "$F_LINE" --arg sev "$(upper "$F_SEV")" \
|
|
882
|
+
--arg prio "$FINDING_PRIORITY" --argjson labels "$LABELS" --arg ms "$MILESTONE_TITLE" --arg batch "$BATCHABLE" --arg dedup "$DEDUP_OF" \
|
|
883
|
+
--arg reg "$REGRESSION" --arg note "$NOTE" --arg cons "$CONSOLIDATE" --arg prior "$PRIOR_NUMS" --arg sub "$SUBSYSTEM" --arg slug "$PATTERN_SLUG" \
|
|
884
|
+
--arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" \
|
|
885
|
+
'{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, agent: $agent, priority: $prio,
|
|
886
|
+
labels: $labels, milestone: $ms, batchable: ($batch == "true"), dedup_of: ($dedup | tonumber? // null), regression_of: ($reg | tonumber? // null),
|
|
887
|
+
note: $note, pattern: {slug: $slug, subsystem: $sub, consolidate: ($cons == "true"), prior: ($prior | split(" ") | map(select(length > 0) | tonumber)), recurrence: $rec}}' >> "$OUT_ITEMS"
|
|
888
|
+
I=$((I + 1))
|
|
889
|
+
done
|
|
890
|
+
jq -s --arg ms "$MILESTONE_TITLE" --arg base "$BASE_BRANCH" --arg mi "$MERGE_ISSUE" --arg sha "$REVIEW_SHA" --argjson pr "$PR_NUMBER" --arg repo "$REPO" \
|
|
891
|
+
'{schema_version: 1, pr: $pr, repo: $repo, review_sha: $sha, base_branch: $base, merge_issue: ($mi | tonumber? // null), milestone: $ms, items: .}' "$OUT_ITEMS" > "$OUT_FILE" \
|
|
892
|
+
|| { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
|
|
893
|
+
echo "WORKLIST_OUT=$OUT_FILE"
|
|
894
|
+
echo "FINDINGS_TO_FILE=$N_FILE"
|
|
895
|
+
echo "MILESTONE=$MILESTONE_TITLE"
|
|
896
|
+
exit 0
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
# ======================================================================================================================
|
|
900
|
+
# verdict: 7A log + 7B base-conflict computation and verdict post
|
|
901
|
+
# ======================================================================================================================
|
|
902
|
+
# Computes BASE_CONFLICT (the single source of truth for "a base sync would fix this"), then posts the verdict comment when --result is
|
|
903
|
+
# given. gh pr review --approve fails for self-reviews, so the verdict is always a --comment review. The LLM-authored pieces (panel
|
|
904
|
+
# summary, finding counts, purpose-regression finding list) arrive as flags and are only ever interpolated as data.
|
|
905
|
+
do_verdict() {
|
|
906
|
+
BASE="${BASE_OPT:-$CTX_BASE_BRANCH}"
|
|
907
|
+
[ -n "$BASE" ] || BASE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
908
|
+
HEAD="$HEAD_BRANCH"
|
|
909
|
+
[ -n "$HEAD" ] || HEAD=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefName --jq '.headRefName' 2>/dev/null || echo "")
|
|
910
|
+
if [ -n "$RESULT" ]; then is_sha "$REVIEW_SHA" || blocked "REVIEW_SHA is not a commit id (pass --review-sha or --context)"; fi
|
|
911
|
+
REVIEW_SHA_SHORT=$(printf '%s' "$REVIEW_SHA" | cut -c1-7)
|
|
912
|
+
# 7A Step 4: the purpose-regression gate log (milestone PRs only; the judgement itself stays with the reviewer, passed as a flag).
|
|
913
|
+
if [ "$IS_MILESTONE" = "true" ]; then
|
|
914
|
+
if [ "$HAS_PURPOSE_REGRESSION" = "true" ]; then
|
|
915
|
+
echo "PURPOSE REGRESSION GATE: BLOCKED"
|
|
916
|
+
echo "Reason: ${PURPOSE_FINDINGS:-finding} contradicts milestone goal"
|
|
917
|
+
echo "Verdict escalated to CHANGES REQUESTED."
|
|
918
|
+
else
|
|
919
|
+
echo "PURPOSE REGRESSION GATE: PASSED"
|
|
920
|
+
echo "No findings contradict the milestone's stated purpose."
|
|
921
|
+
echo "Verdict determined by standard blocking criteria."
|
|
922
|
+
fi
|
|
923
|
+
fi
|
|
924
|
+
HAS_MERGE_CONFLICT=false
|
|
925
|
+
BASE_CONFLICT=false
|
|
926
|
+
MERGE_CONFLICT_MSG=""
|
|
927
|
+
MERGE_PROTECTION_WARNING=""
|
|
928
|
+
BASE_REQUIRES_UPTODATE=false
|
|
929
|
+
if [ "$MERGE_HEALTH_STATE" = "BEHIND" ]; then
|
|
930
|
+
# Unreadable protection (404 / 403 / no admin access) is treated as "not required": no sync, and a later failed merge lands in the existing needs-human path.
|
|
931
|
+
[ "$(gh api "repos/${REPO}/branches/${BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && BASE_REQUIRES_UPTODATE=true
|
|
932
|
+
fi
|
|
933
|
+
if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] || { [ "$MERGE_HEALTH_STATE" = "BEHIND" ] && [ "$BASE_REQUIRES_UPTODATE" = "true" ]; }; then
|
|
934
|
+
HAS_MERGE_CONFLICT=true
|
|
935
|
+
BASE_CONFLICT=true
|
|
936
|
+
MERGE_CONFLICT_MSG="Base conflict with \`${BASE}\`. Merge \`origin/${BASE}\` into \`${HEAD}\` (merge only, no rebase, no force-push), resolve the conflicting files by reading both sides, then re-run /review-pr."
|
|
937
|
+
elif [ "$MERGE_HEALTH_STATE" = "BLOCKED" ]; then
|
|
938
|
+
MERGE_PROTECTION_WARNING="Merge state is BLOCKED: branch protection is not satisfied (required review or check). Syncing the base cannot clear this."
|
|
939
|
+
fi
|
|
940
|
+
# ONLY_BASE_CONFLICT is NOT computed here: CALIBRATION_NEEDS_HUMAN and TRUST_NEEDS_HUMAN are only final at the merge gate.
|
|
941
|
+
ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" "${FORGE_CONFIG:-forge.yaml}" 2>/dev/null | grep "pr_footer:" | awk '{print $(2)}' | tr -d '"')
|
|
942
|
+
[ -n "$ATTRIBUTION_PR_FOOTER" ] || ATTRIBUTION_PR_FOOTER="false"
|
|
943
|
+
ATTRIBUTION_FOOTER_LINE=""
|
|
944
|
+
if [ "$ATTRIBUTION_PR_FOOTER" = "true" ]; then
|
|
945
|
+
ATTRIBUTION_FOOTER_LINE="
|
|
946
|
+
---
|
|
947
|
+
> ⚒️ Orchestrated with [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock) — state, scheduling, review, and memory on GitHub."
|
|
948
|
+
fi
|
|
949
|
+
# Trust annotation: always included so the decision is auditable (defaults mirror Phase 3B.5).
|
|
950
|
+
TRUST_ANNOTATION="**Intensity tier**: ${INTENSITY_TIER:-SHADOW} | **Cell**: \`${TABLE_CELL_KEY:-(unavailable)}\` | **Shadow mode**: ${TRUST_SHADOW_MODE:-true}"
|
|
951
|
+
# A clean verdict can never be posted over a blocker this subcommand computed (criteria 3 and 4): fail closed to CHANGES REQUESTED.
|
|
952
|
+
VERDICT_COERCED=false
|
|
953
|
+
if [ "$RESULT" = "clean" ] && { [ "$BASE_CONFLICT" = "true" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ]; }; then
|
|
954
|
+
RESULT="blocking"; VERDICT_COERCED=true
|
|
955
|
+
fi
|
|
956
|
+
VERDICT_OUT=""
|
|
957
|
+
case "$RESULT" in
|
|
958
|
+
stale) VERDICT_OUT="STALE" ;;
|
|
959
|
+
clean) VERDICT_OUT="APPROVED" ;;
|
|
960
|
+
blocking) VERDICT_OUT="CHANGES REQUESTED" ;;
|
|
961
|
+
esac
|
|
962
|
+
if [ -n "$RESULT" ] && [ "$DRY_RUN" != "true" ]; then
|
|
963
|
+
PURPOSE_LINE=""; [ "$HAS_PURPOSE_REGRESSION" = "true" ] && PURPOSE_LINE="
|
|
964
|
+
⚠ Purpose Regression: ${BLOCKING_COUNT:-[N]} finding(s) contradict the milestone's stated goal and are automatically blocking regardless of runtime impact. See: ${PURPOSE_FINDINGS}"
|
|
965
|
+
CONFLICT_LINE=""; [ "$HAS_MERGE_CONFLICT" = "true" ] && CONFLICT_LINE="
|
|
966
|
+
🔴 Base Conflict: ${MERGE_CONFLICT_MSG}"
|
|
967
|
+
PROTECTION_LINE=""; [ -n "$MERGE_PROTECTION_WARNING" ] && PROTECTION_LINE="
|
|
968
|
+
⚠ ${MERGE_PROTECTION_WARNING}"
|
|
969
|
+
UNKNOWN_LINE=""; [ "$MERGE_HEALTH" = "UNKNOWN" ] && UNKNOWN_LINE="
|
|
970
|
+
⚠ Mergeability: GitHub is still computing merge state (UNKNOWN after retries). Verify manually before merging."
|
|
971
|
+
case "$RESULT" in
|
|
972
|
+
stale)
|
|
973
|
+
gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "Review of commit $REVIEW_SHA_SHORT is stale — PR HEAD changed. Re-run /review-pr." \
|
|
974
|
+
|| blocked "verdict comment could not be posted" ;;
|
|
975
|
+
clean)
|
|
976
|
+
gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "APPROVED: commit $REVIEW_SHA_SHORT after context-aware review (${AGENTS_TEXT:-[N] agents: [names]}). ${FINDINGS_COUNT:-[M]} findings created as issues. Safe to merge.
|
|
977
|
+
${TRUST_ANNOTATION}${UNKNOWN_LINE}${ATTRIBUTION_FOOTER_LINE}" \
|
|
978
|
+
|| blocked "verdict comment could not be posted" ;;
|
|
979
|
+
blocking)
|
|
980
|
+
gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "CHANGES REQUESTED: commit $REVIEW_SHA_SHORT — ${BLOCKING_COUNT:-[N]} blocking issues found. See GitHub issues.
|
|
981
|
+
${TRUST_ANNOTATION}${CONFLICT_LINE}${PROTECTION_LINE}${PURPOSE_LINE}${ATTRIBUTION_FOOTER_LINE}" \
|
|
982
|
+
|| blocked "verdict comment could not be posted" ;;
|
|
983
|
+
esac
|
|
984
|
+
fi
|
|
985
|
+
echo "VERDICT=${VERDICT_OUT}"
|
|
986
|
+
echo "VERDICT_COERCED=${VERDICT_COERCED}"
|
|
987
|
+
echo "HAS_PURPOSE_REGRESSION=${HAS_PURPOSE_REGRESSION}"
|
|
988
|
+
echo "HAS_MERGE_CONFLICT=${HAS_MERGE_CONFLICT}"
|
|
989
|
+
echo "BASE_CONFLICT=${BASE_CONFLICT}"
|
|
990
|
+
echo "MERGE_CONFLICT_MSG=${MERGE_CONFLICT_MSG}"
|
|
991
|
+
echo "MERGE_PROTECTION_WARNING=${MERGE_PROTECTION_WARNING}"
|
|
992
|
+
exit 0
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
# ======================================================================================================================
|
|
996
|
+
# calibrate: 7B.5 calibration threshold consultation
|
|
997
|
+
# ======================================================================================================================
|
|
998
|
+
# Fail-safe: ANY error reading the table yields CALIBRATION_NEEDS_HUMAN=false (static criteria stay authoritative); the table can only tighten.
|
|
999
|
+
do_calibrate() {
|
|
1000
|
+
CALIBRATION_NEEDS_HUMAN=false
|
|
1001
|
+
CALIBRATION_CELL=""
|
|
1002
|
+
CALIBRATION_NOTE=""
|
|
1003
|
+
ISSUE_NUMBER="${MERGE_ISSUE:-}"
|
|
1004
|
+
TASK_TYPE=""; CONFIDENCE=""
|
|
1005
|
+
if [ -n "$ISSUE_NUMBER" ]; then
|
|
1006
|
+
# Trusted, anchored INVESTIGATOR only (body starts with the marker; Bot or OWNER/MEMBER/COLLABORATOR): an
|
|
1007
|
+
# outsider's comment that merely mentions the marker must never feed the calibration lookup or the note.
|
|
1008
|
+
INVESTIGATOR_BODY=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null \
|
|
1009
|
+
| jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:INVESTIGATOR")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))] | last | .body // ""' 2>/dev/null || echo '')
|
|
1010
|
+
# Values reach CALIBRATION_NOTE, which the spec substitutes into a shell fence: restrict to a safe charset.
|
|
1011
|
+
TASK_TYPE=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Task Type\*\*: \(.*\)/\1/p' | head -1 | tr -cd 'A-Za-z0-9_-' | cut -c1-40)
|
|
1012
|
+
CONFIDENCE=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Confidence\*\*: \(.*\)/\1/p' | head -1 | tr -cd 'A-Za-z' | tr '[:lower:]' '[:upper:]')
|
|
1013
|
+
case "$CONFIDENCE" in HIGH|MEDIUM|LOW) ;; *) CONFIDENCE="" ;; esac
|
|
1014
|
+
echo "Phase 7B.5: task_type=${TASK_TYPE:-unknown} confidence=${CONFIDENCE:-unknown} (from issue #${ISSUE_NUMBER})"
|
|
1015
|
+
fi
|
|
1016
|
+
if [ -n "${TASK_TYPE:-}" ] && [ -n "${CONFIDENCE:-}" ]; then
|
|
1017
|
+
CALIB_RAW=$(git show "origin/forge-knowledge:calibration/table.json" 2>/dev/null || echo '')
|
|
1018
|
+
if [ -n "$CALIB_RAW" ]; then
|
|
1019
|
+
CALIB_CELL=$(echo "$CALIB_RAW" | jq -r --arg tt "$TASK_TYPE" --arg c "$CONFIDENCE" \
|
|
1020
|
+
'.rows[] | select(.taskType == $tt and .confidence == $c and .trusted == true)' 2>/dev/null || echo '')
|
|
1021
|
+
if [ -n "$CALIB_CELL" ]; then
|
|
1022
|
+
SURVIVAL_RATE=$(echo "$CALIB_CELL" | jq -r '.survivalRate // "null"' 2>/dev/null || echo 'null')
|
|
1023
|
+
SAMPLE_COUNT=$(echo "$CALIB_CELL" | jq -r '.sampleCount // 0' 2>/dev/null || echo '0')
|
|
1024
|
+
CELL_FLAG=$(echo "$CALIB_CELL" | jq -r '.flag // "null"' 2>/dev/null || echo 'null')
|
|
1025
|
+
CALIBRATION_CELL="${TASK_TYPE} × ${CONFIDENCE}: survival=${SURVIVAL_RATE} (n=${SAMPLE_COUNT})"
|
|
1026
|
+
echo "Phase 7B.5: calibration cell found — ${CALIBRATION_CELL} flag=${CELL_FLAG}"
|
|
1027
|
+
if [ "$CONFIDENCE" = "HIGH" ] && [ "$CELL_FLAG" = "overconfidence" ]; then
|
|
1028
|
+
CALIBRATION_NEEDS_HUMAN=true
|
|
1029
|
+
CALIBRATION_NOTE="Calibration table: ${TASK_TYPE} × HIGH confidence has ${SURVIVAL_RATE} survival rate (< 0.80 threshold, n=${SAMPLE_COUNT}). Routing to needs-human per forge#1741 policy."
|
|
1030
|
+
echo "Phase 7B.5: CALIBRATION_NEEDS_HUMAN=true — ${CALIBRATION_NOTE}"
|
|
1031
|
+
else
|
|
1032
|
+
CALIBRATION_NOTE="Calibration cell: ${CALIBRATION_CELL} — within acceptable threshold"
|
|
1033
|
+
echo "Phase 7B.5: CALIBRATION_NEEDS_HUMAN=false — ${CALIBRATION_NOTE}"
|
|
1034
|
+
fi
|
|
1035
|
+
else
|
|
1036
|
+
CALIBRATION_NOTE="Calibration cell for ${TASK_TYPE} × ${CONFIDENCE} is absent or untrusted — using static behavior"
|
|
1037
|
+
echo "Phase 7B.5: no trusted cell — ${CALIBRATION_NOTE}"
|
|
1038
|
+
fi
|
|
1039
|
+
else
|
|
1040
|
+
CALIBRATION_NOTE="forge-knowledge:calibration/table.json not found — using static behavior"
|
|
1041
|
+
echo "Phase 7B.5: calibration table unavailable — ${CALIBRATION_NOTE}"
|
|
1042
|
+
fi
|
|
1043
|
+
else
|
|
1044
|
+
CALIBRATION_NOTE="task type or confidence not resolved from FORGE:INVESTIGATOR — using static behavior"
|
|
1045
|
+
echo "Phase 7B.5: could not resolve task type/confidence — ${CALIBRATION_NOTE}"
|
|
1046
|
+
fi
|
|
1047
|
+
# TRAJECTORY record: the threshold decision and the cell that justified it.
|
|
1048
|
+
if [ -n "$ISSUE_NUMBER" ] && [ "$DRY_RUN" != "true" ]; then
|
|
1049
|
+
gh issue comment "${ISSUE_NUMBER}" -R "$REPO" --body "<!-- FORGE:CALIBRATION_CHECK -->
|
|
1050
|
+
**Phase 7B.5 — Calibration Threshold Check**
|
|
1051
|
+
**Cell**: ${CALIBRATION_CELL:-not found}
|
|
1052
|
+
**CALIBRATION_NEEDS_HUMAN**: ${CALIBRATION_NEEDS_HUMAN}
|
|
1053
|
+
**Note**: ${CALIBRATION_NOTE}
|
|
1054
|
+
|
|
1055
|
+
**Phase 3B.5 — Provenance Trust Decision**
|
|
1056
|
+
**Intensity tier**: ${INTENSITY_TIER:-SHADOW}
|
|
1057
|
+
**Provenance cell**: \`${TABLE_CELL_KEY:-(unavailable)}\`
|
|
1058
|
+
**Shadow mode**: ${TRUST_SHADOW_MODE:-true}
|
|
1059
|
+
**TRUST_NEEDS_HUMAN**: ${TRUST_NEEDS_HUMAN:-false}
|
|
1060
|
+
**Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)" 2>/dev/null || true
|
|
1061
|
+
fi
|
|
1062
|
+
echo "CALIBRATION_NEEDS_HUMAN=${CALIBRATION_NEEDS_HUMAN}"
|
|
1063
|
+
echo "CALIBRATION_CELL=${CALIBRATION_CELL}"
|
|
1064
|
+
# The orchestrator substitutes this line into a single-quoted shell assignment (review-pr.md Phase 8): never emit
|
|
1065
|
+
# a quote, backtick, dollar sign, backslash or newline (#3918 class).
|
|
1066
|
+
CALIBRATION_NOTE=$(printf '%s' "$CALIBRATION_NOTE" | tr -d "'\`\$\\\\" | tr '\r\n' ' ')
|
|
1067
|
+
echo "CALIBRATION_NOTE=${CALIBRATION_NOTE}"
|
|
1068
|
+
exit 0
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
# ======================================================================================================================
|
|
1072
|
+
# merge-gate: Phase 8 (note disposition, in-PR fix, phase trail, guard state, routing, pre-merge, CI gate, merge)
|
|
1073
|
+
# ======================================================================================================================
|
|
1074
|
+
# Every refusal prints the REVIEW_RESULT line and exits 3 (4 for PHASE_TRAIL_FAILED, which carries the MISSING lines). A parked or
|
|
1075
|
+
# routed outcome (base-conflict, awaiting-merge) is reported through MERGE_OUTCOME. Nothing below a failed gate ever runs.
|
|
1076
|
+
do_merge_gate() {
|
|
1077
|
+
BASE="${BASE_OPT:-$CTX_BASE_BRANCH}"
|
|
1078
|
+
# --- note-disposition preflight: fail closed on an unreadable comment list ---
|
|
1079
|
+
DISPO_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null) || DISPO_JSON=""
|
|
1080
|
+
if [ -z "$DISPO_JSON" ]; then
|
|
1081
|
+
echo "NOTE DISPOSITION: could not read PR comments — refusing to merge (fail closed)" >&2
|
|
1082
|
+
blocked "note disposition unreadable"
|
|
1083
|
+
fi
|
|
1084
|
+
FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length' 2>/dev/null)
|
|
1085
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
1086
|
+
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$FINDING_COUNT" ]; then
|
|
1087
|
+
echo "NOTE DISPOSITION: scripts/trusted-comments.sh unresolvable or findings unreadable — refusing to merge (fail closed)" >&2
|
|
1088
|
+
blocked "note disposition unreadable"
|
|
1089
|
+
fi
|
|
1090
|
+
DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:NOTE_DISPOSITION') || DISPOSITION_COUNT=""
|
|
1091
|
+
if [ -z "$DISPOSITION_COUNT" ]; then
|
|
1092
|
+
echo "NOTE DISPOSITION: trust filter failed — refusing to merge (fail closed)" >&2
|
|
1093
|
+
blocked "note disposition unreadable"
|
|
1094
|
+
fi
|
|
1095
|
+
if [ "${FINDING_COUNT:-0}" -gt 0 ] && [ "${DISPOSITION_COUNT:-0}" -eq 0 ]; then
|
|
1096
|
+
echo "NOTE DISPOSITION: ${FINDING_COUNT} finding comment(s) but no FORGE:NOTE_DISPOSITION record — run §6B.5 before merging" >&2
|
|
1097
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "Auto-merge skipped: review findings exist but §6B.5 note disposition was not recorded. Re-run \`/review-pr ${PR_NUMBER} --auto-merge --issue <N>\`." 2>/dev/null || true # allowlist:check-command-side-effects
|
|
1098
|
+
blocked "note disposition missing"
|
|
1099
|
+
fi
|
|
1100
|
+
# --- in-PR fix gate (§6B.6): an unanswered INPR_FIX request at the current head blocks the merge ---
|
|
1101
|
+
PR_HEAD_NOW=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq .headRefOid 2>/dev/null || echo "")
|
|
1102
|
+
INPR_BODIES=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INPR_FIX: ') || INPR_BODIES="__ERR__"
|
|
1103
|
+
INPR_WAIVED=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:INPR_FIX_WAIVED: head=${PR_HEAD_NOW}") || INPR_WAIVED=""
|
|
1104
|
+
if [ "$INPR_BODIES" = "__ERR__" ] || [ -z "$INPR_WAIVED" ]; then
|
|
1105
|
+
echo "IN-PR FIX: trust filter failed — refusing to merge (fail closed)" >&2
|
|
1106
|
+
blocked "note disposition unreadable"
|
|
1107
|
+
fi
|
|
1108
|
+
# Latest trusted request wins (bodies are JSON strings, one per line).
|
|
1109
|
+
INPR_REQ=$(printf '%s\n' "$INPR_BODIES" | tail -n 1 | jq -r 'capture("head=(?<h>[0-9a-f]{7,40})").h // ""' 2>/dev/null || echo "")
|
|
1110
|
+
if [ -n "$INPR_REQ" ] && [ "$INPR_REQ" = "$PR_HEAD_NOW" ] && [ "${INPR_WAIVED:-0}" -eq 0 ]; then
|
|
1111
|
+
echo "IN-PR FIX: CONFIRMED findings in this PR's files were requested at head ${PR_HEAD_NOW} — fix them before merging (§6B.6)" >&2
|
|
1112
|
+
# Not needs-human: /work-on R4 runs one fix round.
|
|
1113
|
+
blocked "in-pr fix required"
|
|
1114
|
+
fi
|
|
1115
|
+
|
|
1116
|
+
# --- phase-trail preflight: a reviewer verdict alone must not merge work whose earlier phases were skipped ---
|
|
1117
|
+
if [ -z "${MERGE_ISSUE:-}" ]; then
|
|
1118
|
+
echo "PHASE TRAIL: auto-merge requested without --issue — the issue is never inferred from PR-author-controlled text, so the phase trail cannot be verified; refusing to merge (fail closed)" >&2
|
|
1119
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "Auto-merge skipped: no --issue was given, so the phase trail cannot be verified. Re-run \`/review-pr ${PR_NUMBER} --auto-merge --issue <N>\` or merge manually." 2>/dev/null || true # allowlist:check-command-side-effects
|
|
1120
|
+
# Not a PHASE_TRAIL_FAILED (nothing to re-run).
|
|
1121
|
+
blocked "auto-merge requires --issue"
|
|
1122
|
+
fi
|
|
1123
|
+
TRAIL_SCRIPT=$(forge_resolve verify-phase-trail.sh); DOCS_ONLY_SCRIPT=$(forge_resolve is-docs-only.sh)
|
|
1124
|
+
# Docs-only predicate: ONE shared copy (scripts/is-docs-only.sh). Fail closed: unresolved script or empty diff -> no flag.
|
|
1125
|
+
DOCS_ONLY_FLAG=""
|
|
1126
|
+
# Both sides of a rename (filename + previous_filename): `gh pr diff --name-only` collapses a rename to its destination.
|
|
1127
|
+
PR_FILES_ALL=$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[] | .filename, (.previous_filename // empty)' 2>/dev/null) || PR_FILES_ALL=""
|
|
1128
|
+
# Fail closed on truncation: the files API caps at 3000 files, so a PR at/over the cap (or an unreadable count) never yields a docs-only flag.
|
|
1129
|
+
PR_CHANGED_N=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .changed_files 2>/dev/null) || PR_CHANGED_N=""
|
|
1130
|
+
case "$PR_CHANGED_N" in ''|*[!0-9]*) PR_FILES_ALL="" ;; *) [ "$PR_CHANGED_N" -ge 3000 ] && PR_FILES_ALL="" ;; esac
|
|
1131
|
+
if [ -n "$PR_FILES_ALL" ] && [ -n "$DOCS_ONLY_SCRIPT" ] && echo "$PR_FILES_ALL" | bash "$DOCS_ONLY_SCRIPT"; then DOCS_ONLY_FLAG="--docs-only"; fi
|
|
1132
|
+
# Band cross-check: a non-docs PR diff means an agent-chosen INVESTIGATION band must not waive requirements. Anything not positively docs-only is a code diff.
|
|
1133
|
+
CODE_DIFF_FLAG="--code-diff"
|
|
1134
|
+
if [ -n "$DOCS_ONLY_FLAG" ]; then CODE_DIFF_FLAG=""; fi
|
|
1135
|
+
OVERRIDE_BOUND_HEAD=""
|
|
1136
|
+
if [ -z "$TRAIL_SCRIPT" ]; then
|
|
1137
|
+
# Fail closed: an unresolvable verifier is NOT a pass (never fall back to the consumer repo).
|
|
1138
|
+
echo "PHASE TRAIL: verify-phase-trail.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install) — refusing to merge" >&2
|
|
1139
|
+
TRAIL="PHASE_TRAIL: ERROR (verifier not resolvable)"; TRAIL_RC=127
|
|
1140
|
+
else
|
|
1141
|
+
# Bind the human break-glass override to the PR head. An unreadable head omits the flag, so no override is offered (fails closed).
|
|
1142
|
+
PR_HEAD_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .head.sha 2>/dev/null) || PR_HEAD_SHA=""
|
|
1143
|
+
HEAD_SHA_ARGS=()
|
|
1144
|
+
if [[ "$PR_HEAD_SHA" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then HEAD_SHA_ARGS=(--head-sha "$PR_HEAD_SHA"); fi
|
|
1145
|
+
TRAIL=$(bash "$TRAIL_SCRIPT" "$MERGE_ISSUE" -R "$REPO" $DOCS_ONLY_FLAG $CODE_DIFF_FLAG ${HEAD_SHA_ARGS[@]+"${HEAD_SHA_ARGS[@]}"}); TRAIL_RC=$?
|
|
1146
|
+
if [ "$TRAIL_RC" -eq 0 ]; then
|
|
1147
|
+
# An `OVERRIDE:` line means the verifier accepted a human override. Record it from the verifier's output (never agent prose).
|
|
1148
|
+
OVR=$(printf '%s\n' "$TRAIL" | sed -n 's/^OVERRIDE: //p' | sed -n '1p')
|
|
1149
|
+
if [ -n "$OVR" ]; then
|
|
1150
|
+
OVERRIDE_BOUND_HEAD="$PR_HEAD_SHA" # the merge below must land exactly this commit
|
|
1151
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "$(printf '<!-- FORGE:PHASE_TRAIL_OVERRIDE_APPLIED -->\nThe phase-trail gate was waived by a human break-glass override.\n\n%s\n%s\n%s\n' '```' "$OVR" '```')" # <!-- allowlist:check-command-side-effects -->
|
|
1152
|
+
fi
|
|
1153
|
+
fi
|
|
1154
|
+
fi
|
|
1155
|
+
if [ "$TRAIL_RC" -ge 2 ]; then
|
|
1156
|
+
# rc 2 (trail unreadable) / 127 (verifier not resolvable) is an infrastructure failure, NOT missing phases: never report PHASE_TRAIL_FAILED here.
|
|
1157
|
+
TRAIL_ERR_BODY="<!-- FORGE:PHASE_TRAIL_ERROR -->
|
|
1158
|
+
Auto-merge refused for PR #${PR_NUMBER}: the phase-trail verifier could not run (rc=${TRAIL_RC}). This is an infrastructure problem, not a missing phase. Do not re-run phases.
|
|
1159
|
+
|
|
1160
|
+
\`\`\`
|
|
1161
|
+
${TRAIL}
|
|
1162
|
+
\`\`\`
|
|
1163
|
+
|
|
1164
|
+
Fix the cause (gh auth/API outage, \`FORGE_TRAIL_QG_SINCE\`/runner clock, or set \`FORGEDOCK_HOME\` when the verifier is unresolvable), then re-run /review-pr."
|
|
1165
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "$TRAIL_ERR_BODY" # <!-- allowlist:check-command-side-effects -->
|
|
1166
|
+
# Nothing can resume this automatically, so park it for a human instead of leaving workflow:in-review.
|
|
1167
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
|
|
1168
|
+
# The PR stays open and unmerged; callers must NOT fall back to a manual merge.
|
|
1169
|
+
blocked "phase trail unreadable (rc=${TRAIL_RC})"
|
|
1170
|
+
elif [ "$TRAIL_RC" -ne 0 ]; then
|
|
1171
|
+
TRAIL_FAIL_BODY="<!-- FORGE:PHASE_TRAIL_FAILED -->
|
|
1172
|
+
Auto-merge refused for PR #${PR_NUMBER}: the issue's phase trail is incomplete.
|
|
1173
|
+
|
|
1174
|
+
\`\`\`
|
|
1175
|
+
${TRAIL}
|
|
1176
|
+
\`\`\`
|
|
1177
|
+
|
|
1178
|
+
Re-run each missing phase via its Skill (see the \`->\` action on each MISSING line), then re-run /review-pr."
|
|
1179
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "$TRAIL_FAIL_BODY" # <!-- allowlist:check-command-side-effects -->
|
|
1180
|
+
# rc 1 only; the work-on review phase (work-on/review.md R4) is the single owner of re-dispatching the named phases. Not an immediate needs-human escalation.
|
|
1181
|
+
echo "REVIEW_RESULT: status: PHASE_TRAIL_FAILED"
|
|
1182
|
+
printf '%s\n' "$TRAIL"
|
|
1183
|
+
exit 4
|
|
1184
|
+
fi
|
|
1185
|
+
[ "${TRAIL_RC:-1}" -eq 0 ] || blocked "phase trail unreadable (rc=${TRAIL_RC:-1})" # hard guard: nothing below runs unless the trail verified
|
|
1186
|
+
|
|
1187
|
+
# --- merge guard state: §7B verdict + purpose-regression + calibration + trust-escalation ---
|
|
1188
|
+
# These are decided in earlier steps (earlier shells), so they arrive as flags. An empty or unbound value would read as
|
|
1189
|
+
# "not blocking" and fall through to the merge path, so any value outside the expected set stops here instead of merging.
|
|
1190
|
+
[ "$VERDICT" = "APPROVE" ] && VERDICT="APPROVED"
|
|
1191
|
+
GUARD_STATE_OK=true
|
|
1192
|
+
case "$VERDICT" in "APPROVED"|"CHANGES REQUESTED") ;; *) GUARD_STATE_OK=false ;; esac
|
|
1193
|
+
for _guard_val in "$HAS_PURPOSE_REGRESSION" "$CALIBRATION_NEEDS_HUMAN" "$TRUST_NEEDS_HUMAN" "$BASE_CONFLICT" "$OTHER_BLOCKING"; do
|
|
1194
|
+
case "$_guard_val" in true|false) ;; *) GUARD_STATE_OK=false ;; esac
|
|
1195
|
+
done
|
|
1196
|
+
if [ "$GUARD_STATE_OK" != "true" ]; then
|
|
1197
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: the Phase 8 merge guard state is unbound (VERDICT='${VERDICT}', HAS_PURPOSE_REGRESSION='${HAS_PURPOSE_REGRESSION}', CALIBRATION_NEEDS_HUMAN='${CALIBRATION_NEEDS_HUMAN}', TRUST_NEEDS_HUMAN='${TRUST_NEEDS_HUMAN}', BASE_CONFLICT='${BASE_CONFLICT}', OTHER_BLOCKING='${OTHER_BLOCKING}'). Not merging; re-run /review-pr." # <!-- allowlist:check-command-side-effects -->
|
|
1198
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
|
|
1199
|
+
blocked "merge guard state unbound"
|
|
1200
|
+
fi
|
|
1201
|
+
# ONLY_BASE_CONFLICT: the base conflict is the ONLY reason for CHANGES REQUESTED, evaluated after every human gate is set.
|
|
1202
|
+
ONLY_BASE_CONFLICT=false
|
|
1203
|
+
if [ "$BASE_CONFLICT" = "true" ] && [ "$OTHER_BLOCKING" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ] && [ "$CALIBRATION_NEEDS_HUMAN" != "true" ] && [ "$TRUST_NEEDS_HUMAN" != "true" ]; then
|
|
1204
|
+
ONLY_BASE_CONFLICT=true
|
|
1205
|
+
fi
|
|
1206
|
+
if [ "$ONLY_BASE_CONFLICT" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
|
|
1207
|
+
# Pure base conflict: route to the base-sync path, NOT needs-human. A standalone /review-pr caller just sees the blocker.
|
|
1208
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push), re-running the quality gate, then re-reviewing the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
|
|
1209
|
+
echo "MERGE_OUTCOME=base-conflict"
|
|
1210
|
+
blocked "base-conflict"
|
|
1211
|
+
elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "$TRUST_NEEDS_HUMAN" = "true" ]; then
|
|
1212
|
+
BLOCK_REASON=""
|
|
1213
|
+
[ "$VERDICT" = "CHANGES REQUESTED" ] && BLOCK_REASON="review verdict is CHANGES REQUESTED (blocking finding confirmed by Phase 7B)"
|
|
1214
|
+
[ "$HAS_PURPOSE_REGRESSION" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }purpose regression detected by Phase 7A (\`HAS_PURPOSE_REGRESSION=true\`)"
|
|
1215
|
+
[ "$CALIBRATION_NEEDS_HUMAN" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }calibration threshold: ${CALIBRATION_NOTE}"
|
|
1216
|
+
[ "$TRUST_NEEDS_HUMAN" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }provenance trust: NOVEL_NEEDS_HUMAN tier for cell \`${TABLE_CELL_KEY}\` — no sufficient prior data (forge#1745)"
|
|
1217
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: ${BLOCK_REASON}. Manual review required before merging."
|
|
1218
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true
|
|
1219
|
+
echo "MERGE_OUTCOME=needs-human"
|
|
1220
|
+
blocked "auto-merge blocked: ${BLOCK_REASON}"
|
|
1221
|
+
fi
|
|
1222
|
+
|
|
1223
|
+
# --- pre-merge mergeability guard: re-fetch fresh state (the base may have moved while the review ran) ---
|
|
1224
|
+
PRE_MERGE_RESULT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\(.mergeable)|\(.mergeStateStatus)"')
|
|
1225
|
+
PRE_MERGE_HEALTH=${PRE_MERGE_RESULT%%|*}
|
|
1226
|
+
PRE_MERGE_HEALTH_STATE=${PRE_MERGE_RESULT##*|}
|
|
1227
|
+
PRE_MERGE_BASE_CONFLICT=false
|
|
1228
|
+
if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIRTY" ]; then
|
|
1229
|
+
PRE_MERGE_BASE_CONFLICT=true
|
|
1230
|
+
elif [ "$PRE_MERGE_HEALTH_STATE" = "BEHIND" ]; then
|
|
1231
|
+
# BEHIND is a conflict only under required-up-to-date protection; unreadable protection counts as "not required".
|
|
1232
|
+
[ "$(gh api "repos/${REPO}/branches/${BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && PRE_MERGE_BASE_CONFLICT=true
|
|
1233
|
+
fi
|
|
1234
|
+
if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
|
|
1235
|
+
# Base moved during review: route to the base-sync path, NOT needs-human.
|
|
1236
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push) and then re-reviewing. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
|
|
1237
|
+
echo "MERGE_OUTCOME=base-conflict"
|
|
1238
|
+
blocked "base-conflict"
|
|
1239
|
+
elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
|
|
1240
|
+
# Branch protection not satisfied: syncing the base cannot clear it, so this is NOT a base-conflict blocker.
|
|
1241
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: branch protection is not satisfied (\`mergeStateStatus=BLOCKED\`); syncing the base cannot clear it. Satisfy the required review/checks, then re-run /review-pr."
|
|
1242
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true
|
|
1243
|
+
echo "MERGE_OUTCOME=protection-blocked"
|
|
1244
|
+
blocked "branch protection not satisfied"
|
|
1245
|
+
fi
|
|
1246
|
+
|
|
1247
|
+
# --- previously-escalated re-review guard (base-scoped): park at awaiting-merge, never merge here ---
|
|
1248
|
+
PREVIOUSLY_ESCALATED=$(gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels,comments \
|
|
1249
|
+
--jq '([.labels[].name | . == "needs-human" or . == "workflow:remediating"] + [.comments[].body | contains("FORGE:REMEDIATION")]) | any' 2>/dev/null || echo "false")
|
|
1250
|
+
GUARD_BASE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
1251
|
+
if [ "$PREVIOUSLY_ESCALATED" = "true" ]; then
|
|
1252
|
+
# `jq -r '.baseRefName'` stringifies a JSON null to the text "null": the resolver treats that like an unresolved base (strict).
|
|
1253
|
+
GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
|
|
1254
|
+
if [ -n "$GATE_SCRIPT" ]; then GUARD_GATE=$(bash "$GATE_SCRIPT" "$GUARD_BASE" "$REPO" 2>/dev/null || echo true); else GUARD_GATE=true; fi; [ "$GUARD_GATE" = "false" ] || GUARD_GATE=true
|
|
1255
|
+
if [ "$GUARD_GATE" = "false" ]; then
|
|
1256
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "🟠 PR #${PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check on non-default-branch base \`${GUARD_BASE}\`. It is parked at \`workflow:awaiting-merge\` for the remediation auto-land bar (\`remediate.md\` Phase M7, forge#2570) — which auto-merges it on the same fast-lane condition the normal \`/work-on\` path uses for this target branch — NOT for a human merge decision. If no remediation \`M7\` follows (e.g. this review was invoked standalone), merge manually once reviewed: \`gh pr merge ${PR_NUMBER} -R ${REPO} --merge\`."
|
|
1257
|
+
else
|
|
1258
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "🟠 PR #${PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check. This PR targets the deploy gate (\`${GUARD_BASE:-unresolved base}\`), so it is held at \`workflow:awaiting-merge\` for a human merge decision — \`staging → main\` is the genuine human gate. Merge manually once reviewed: \`gh pr merge ${PR_NUMBER} -R ${REPO} --merge\`."
|
|
1259
|
+
fi
|
|
1260
|
+
# transition-label.sh: install roots only. Unresolvable falls through to the prose label edit so the transition is never silently skipped.
|
|
1261
|
+
TRANSITION_SCRIPT=$(forge_resolve transition-label.sh)
|
|
1262
|
+
if [ -n "$TRANSITION_SCRIPT" ]; then
|
|
1263
|
+
bash "$TRANSITION_SCRIPT" "$MERGE_ISSUE" -R "$REPO" awaiting-merge
|
|
1264
|
+
else
|
|
1265
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "workflow:awaiting-merge" \
|
|
1266
|
+
--remove-label "needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
|
|
1267
|
+
fi
|
|
1268
|
+
# Do not merge here: a human decides for a deploy-gate base, remediate.md Phase M7's auto-land bar for any other base.
|
|
1269
|
+
echo "MERGE_OUTCOME=parked-awaiting-merge"
|
|
1270
|
+
echo "GUARD_GATE=${GUARD_GATE}"
|
|
1271
|
+
exit 0
|
|
1272
|
+
fi
|
|
1273
|
+
|
|
1274
|
+
# --- CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is green ---
|
|
1275
|
+
CI_GATE_SCRIPT=$(forge_resolve wait-ci-green.sh)
|
|
1276
|
+
if [ -n "$CI_GATE_SCRIPT" ]; then CI_GATE_OUT=$(bash "$CI_GATE_SCRIPT" "$PR_NUMBER" -R "$REPO"); CI_GATE_RC=$?
|
|
1277
|
+
else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
|
|
1278
|
+
echo "$CI_GATE_OUT"
|
|
1279
|
+
GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
|
|
1280
|
+
# rc 2 "head moved" is a stale review, not a human problem; rc 3 TIMEOUT means CI is still running.
|
|
1281
|
+
printf '%s\n' "$CI_GATE_OUT" | grep -q 'PR head moved during the wait' && CI_GATE_RC=4
|
|
1282
|
+
if [ "$CI_GATE_RC" -eq 3 ]; then
|
|
1283
|
+
echo "CI_GATE_TIMEOUT: CI still running — re-run this subcommand (up to 3 more times) before treating it as a failure."
|
|
1284
|
+
fi
|
|
1285
|
+
# Reviewed-head guard: merge only the exact commit this review approved. REVIEWED_SHORT is re-derived from this review's
|
|
1286
|
+
# FORGE:REVIEW_ROUTE marker, never assumed. A docs-only delta (this review's own §6B.5 note fixes) is allowed; any other change is stale.
|
|
1287
|
+
REVIEWED_SHORT=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate --jq '.[] | select(.body | contains("FORGE:REVIEW_ROUTE")) | .body' 2>/dev/null \
|
|
1288
|
+
| sed -n 's/.*FORGE:REVIEW_ROUTE[^>]* sha=\([0-9a-f]\{7,\}\).*/\1/p' | tail -1)
|
|
1289
|
+
if [ "$CI_GATE_RC" -eq 0 ]; then
|
|
1290
|
+
if [ -z "$REVIEWED_SHORT" ] || [ -z "$GATED_HEAD" ] || [ "$GATED_HEAD" = "unknown" ]; then
|
|
1291
|
+
CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: cannot bind the merge to a reviewed commit (route sha='${REVIEWED_SHORT}', gated head='${GATED_HEAD}')"
|
|
1292
|
+
elif [ "${GATED_HEAD#"$REVIEWED_SHORT"}" = "$GATED_HEAD" ]; then
|
|
1293
|
+
REVIEW_BASE_SHA=$(gh api "repos/${REPO}/commits/${REVIEWED_SHORT}" --jq '.sha' 2>/dev/null)
|
|
1294
|
+
POST_REVIEW_FILES=$(gh api "repos/${REPO}/compare/${REVIEW_BASE_SHA:-$REVIEWED_SHORT}...${GATED_HEAD}" --jq '.files[] | .filename, (.previous_filename // empty)' 2>/dev/null)
|
|
1295
|
+
if [ -n "$POST_REVIEW_FILES" ] && [ -n "$DOCS_ONLY_SCRIPT" ] && printf '%s\n' "$POST_REVIEW_FILES" | bash "$DOCS_ONLY_SCRIPT"; then
|
|
1296
|
+
echo "Post-review commits are docs-only (note fixes) — reviewed code unchanged."
|
|
1297
|
+
else
|
|
1298
|
+
CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: PR head ${GATED_HEAD:0:7} is not the reviewed commit ${REVIEWED_SHORT} and the delta is not docs-only"
|
|
1299
|
+
fi
|
|
1300
|
+
fi
|
|
1301
|
+
fi
|
|
1302
|
+
# An accepted break-glass override is bound to one exact head: a push after the verifier ran (even a docs-only one) is not covered.
|
|
1303
|
+
if [ "$CI_GATE_RC" -eq 0 ] && [ -n "${OVERRIDE_BOUND_HEAD:-}" ] && [ "$GATED_HEAD" != "$OVERRIDE_BOUND_HEAD" ]; then
|
|
1304
|
+
CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: break-glass override was bound to ${OVERRIDE_BOUND_HEAD:0:7} but the gated head is ${GATED_HEAD:0:7}"
|
|
1305
|
+
fi
|
|
1306
|
+
if [ "$CI_GATE_RC" -eq 4 ]; then
|
|
1307
|
+
# Not a human problem: the caller re-runs /review-pr on the new head (work-on/review.md R4).
|
|
1308
|
+
echo "MERGE_OUTCOME=stale-review"
|
|
1309
|
+
blocked "stale review — ${CI_GATE_OUT}; re-run /review-pr on the new head"
|
|
1310
|
+
elif [ "$CI_GATE_RC" -eq 3 ]; then
|
|
1311
|
+
# TIMEOUT = checks still pending, not failing (wait-ci-green.sh rc=3). Not a human problem: no needs-human label,
|
|
1312
|
+
# so the documented re-run (or engine retry) merges once CI is green instead of parking behind the
|
|
1313
|
+
# previously-escalated guard (#3919).
|
|
1314
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⏳ Auto-merge deferred for PR #${PR_NUMBER}: CI checks still pending after the wait window (ci gate rc=3). Re-run the merge gate once they finish; nothing to fix." 2>/dev/null || true # allowlist:check-command-side-effects
|
|
1315
|
+
echo "MERGE_OUTCOME=ci-pending"
|
|
1316
|
+
blocked "checks still pending after the wait window (timeout) — re-run the merge once CI finishes"
|
|
1317
|
+
elif [ "$CI_GATE_RC" -ne 0 ]; then
|
|
1318
|
+
CI_MSG="⛔ Auto-merge refused for PR #${PR_NUMBER}: CI is not green (ci gate rc=${CI_GATE_RC}). The PR stays open; fix the failing checks on its branch (remediation treats this as FIXABLE) or re-run flaky ones.
|
|
1319
|
+
\`\`\`
|
|
1320
|
+
${CI_GATE_OUT}
|
|
1321
|
+
\`\`\`"
|
|
1322
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "$CI_MSG" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
1323
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
1324
|
+
echo "MERGE_OUTCOME=ci-not-green"
|
|
1325
|
+
blocked "ci gate not green (rc=${CI_GATE_RC})"
|
|
1326
|
+
fi
|
|
1327
|
+
|
|
1328
|
+
# --- merge exactly the commit the CI gate (and the reviewed-head guard) checked ---
|
|
1329
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "Review complete for PR #${PR_NUMBER}. Verdict: ${VERDICT:-APPROVED}. CI green. Proceeding to merge."
|
|
1330
|
+
gh pr merge "$PR_NUMBER" -R "$REPO" --merge --match-head-commit "$GATED_HEAD"
|
|
1331
|
+
MERGE_STATE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json state --jq '.state')
|
|
1332
|
+
if [ "$MERGE_STATE" != "MERGED" ]; then
|
|
1333
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "PR #${PR_NUMBER} merge failed. State: $MERGE_STATE."
|
|
1334
|
+
echo "MERGE_OUTCOME=merge-failed"
|
|
1335
|
+
else
|
|
1336
|
+
echo "MERGE_OUTCOME=merged"
|
|
1337
|
+
fi
|
|
1338
|
+
echo "MERGE_STATE=${MERGE_STATE}"
|
|
1339
|
+
echo "MERGED_HEAD=${GATED_HEAD}"
|
|
1340
|
+
exit 0
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
# ======================================================================================================================
|
|
1344
|
+
# demilestone: 8B (merge just happened) and 9A (manual-merge fallback) review-finding demilestoning
|
|
1345
|
+
# ======================================================================================================================
|
|
1346
|
+
# With --merge-state the caller reports the state it just observed (8B: only MERGED runs); without it the state is read from the PR (9A).
|
|
1347
|
+
do_demilestone() {
|
|
1348
|
+
[ "$IS_MILESTONE" = "true" ] || { echo "DEMILESTONED=0"; exit 0; }
|
|
1349
|
+
if [ -z "$MERGE_STATE_IN" ]; then MERGE_STATE_IN=$(gh pr view "$PR_NUMBER" -R "$REPO" --json state --jq '.state' 2>/dev/null || echo ""); fi
|
|
1350
|
+
[ "$MERGE_STATE_IN" = "MERGED" ] || { echo "DEMILESTONED=0"; exit 0; }
|
|
1351
|
+
echo "Phase 8B/9A: Clearing milestone from open review-finding issues referencing PR #${PR_NUMBER}..."
|
|
1352
|
+
# The title template in Phase 6C always includes: "review finding — PR #${PR_NUMBER}"
|
|
1353
|
+
FINDINGS_TO_DEMILESTONE=$(gh issue list -R "${REPO}" \
|
|
1354
|
+
--state open \
|
|
1355
|
+
--label "review-finding" \
|
|
1356
|
+
--limit 200 \
|
|
1357
|
+
--json number,title,milestone \
|
|
1358
|
+
--jq ".[] | select(.milestone != null) | select(.title | test(\"PR #${PR_NUMBER}\")) | .number" \
|
|
1359
|
+
2>/dev/null || echo "")
|
|
1360
|
+
MOVED_COUNT=0
|
|
1361
|
+
if [ -z "$FINDINGS_TO_DEMILESTONE" ]; then
|
|
1362
|
+
echo "Phase 8B/9A: No open review-finding issues with milestones found referencing PR #${PR_NUMBER} (already cleared or none created)."
|
|
1363
|
+
else
|
|
1364
|
+
for FINDING_NUM in $FINDINGS_TO_DEMILESTONE; do
|
|
1365
|
+
case "$FINDING_NUM" in ''|*[!0-9]*) continue ;; esac
|
|
1366
|
+
FINDING_TITLE=$(gh issue view "$FINDING_NUM" -R "${REPO}" --json title --jq '.title' 2>/dev/null || echo "#${FINDING_NUM}")
|
|
1367
|
+
if gh issue edit "$FINDING_NUM" -R "${REPO}" --milestone "" 2>/dev/null; then
|
|
1368
|
+
echo " Moved to fast lane: #${FINDING_NUM} — ${FINDING_TITLE}"
|
|
1369
|
+
MOVED_COUNT=$((MOVED_COUNT + 1))
|
|
1370
|
+
else
|
|
1371
|
+
echo " WARNING: Failed to clear milestone for #${FINDING_NUM}"
|
|
1372
|
+
fi
|
|
1373
|
+
done
|
|
1374
|
+
echo "Phase 8B/9A: Review finding demilestoning complete."
|
|
1375
|
+
fi
|
|
1376
|
+
echo "DEMILESTONED=${MOVED_COUNT}"
|
|
1377
|
+
exit 0
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
# ======================================================================================================================
|
|
1381
|
+
# integrity: Phase 9 stale check and verifiable agent count
|
|
1382
|
+
# ======================================================================================================================
|
|
1383
|
+
do_integrity() {
|
|
1384
|
+
is_sha "$REVIEW_SHA" || blocked "REVIEW_SHA is not a commit id (pass --review-sha or --context)"
|
|
1385
|
+
CURRENT_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
|
|
1386
|
+
REVIEW_IS_STALE="false"
|
|
1387
|
+
# An unreadable head is stale (fail closed): the summary must never claim CURRENT without having read the head.
|
|
1388
|
+
if [ -z "$CURRENT_SHA" ] || [ "$CURRENT_SHA" != "$REVIEW_SHA" ]; then REVIEW_IS_STALE="true"; fi
|
|
1389
|
+
# Same trust rule as Phase 3C/4: only trusted comments whose body starts with the marker count.
|
|
1390
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
1391
|
+
REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
|
|
1392
|
+
BASE_BRANCH="${BASE_OPT:-$CTX_BASE_BRANCH}"
|
|
1393
|
+
[ -n "$BASE_BRANCH" ] || BASE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
1394
|
+
COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
|
|
1395
|
+
_dm=$(forge_resolve lib/review-delta-mode.sh); [ -n "$_dm" ] && . "$_dm" || { review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; }
|
|
1396
|
+
# BASE_SYNC_ONLY: zero reviewers is the expected panel only when the carry record is trusted and the resolver re-verifies it.
|
|
1397
|
+
CARRIED_FROM=""
|
|
1398
|
+
if [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ]; then CARRIED_FROM=$(carry_verified_from); fi
|
|
1399
|
+
ACTUAL_AGENT_DOMAINS=""
|
|
1400
|
+
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
1401
|
+
ACTUAL_AGENT_DOMAINS=$(printf '%s' "$COMMENTS_JSON" \
|
|
1402
|
+
| bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
|
|
1403
|
+
| jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
|
|
1404
|
+
fi
|
|
1405
|
+
# No `|| echo 0` fallback after grep -c: it always prints a count (0 on no match) even though it exits 1, so a fallback would double-count to "0\n0".
|
|
1406
|
+
ACTUAL_AGENT_COUNT=$(echo "$ACTUAL_AGENT_DOMAINS" | tr ',' '\n' | grep -c '.' 2>/dev/null)
|
|
1407
|
+
# A verified carried head dispatched no reviewers by design: report it as carried instead of a degraded zero-agent panel.
|
|
1408
|
+
if [ -n "$CARRIED_FROM" ]; then ACTUAL_AGENT_DOMAINS="carried from ${CARRIED_FROM}"; ACTUAL_AGENT_COUNT=0; fi
|
|
1409
|
+
echo "REVIEW_SHA=${REVIEW_SHA}"
|
|
1410
|
+
echo "CURRENT_SHA=${CURRENT_SHA}"
|
|
1411
|
+
echo "REVIEW_IS_STALE=${REVIEW_IS_STALE}"
|
|
1412
|
+
echo "ACTUAL_AGENT_COUNT=${ACTUAL_AGENT_COUNT}"
|
|
1413
|
+
echo "ACTUAL_AGENT_DOMAINS=${ACTUAL_AGENT_DOMAINS}"
|
|
1414
|
+
echo "CARRIED_FROM: ${CARRIED_FROM}"
|
|
1415
|
+
exit 0
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
case "$SUB" in
|
|
1419
|
+
extract) do_extract ;;
|
|
1420
|
+
classify) do_classify ;;
|
|
1421
|
+
file-prepare) do_file_prepare ;;
|
|
1422
|
+
verdict) do_verdict ;;
|
|
1423
|
+
calibrate) do_calibrate ;;
|
|
1424
|
+
merge-gate) do_merge_gate ;;
|
|
1425
|
+
demilestone) do_demilestone ;;
|
|
1426
|
+
integrity) do_integrity ;;
|
|
1427
|
+
esac
|