forgedock 1.16.2 → 1.17.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 +92 -0
- package/bin/engine/phases.mjs +86 -26
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -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 +12 -1
- package/bin/engine.mjs +145 -40
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -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 +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +17 -2
- 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 +102 -25
- package/commands/review-pr.md +119 -103
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +3 -2
- package/commands/work-on/build.md +95 -64
- package/commands/work-on/close.md +17 -6
- package/commands/work-on/decompose.md +45 -4
- package/commands/work-on/investigate.md +6 -2
- package/commands/work-on/remediate.md +22 -6
- package/commands/work-on/review.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +13 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- 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-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 +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +5 -0
- 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 +8 -3
- 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/remediate-liveness.sh +42 -6
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-prepare.sh — deterministic review-pr preparation (Phases 1-3B plus the 3C delta-mode and diff-slice steps).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# One self-contained shell: it re-derives every value it uses (no helper or variable is defined in one place and consumed in
|
|
7
|
+
# another), resolves ForgeDock's own helpers through forge_resolve (FORGEDOCK_HOME / plugin root / FORGE_ROOT / plugin cache; the
|
|
8
|
+
# working directory and the PR checkout are NEVER a script source), and treats everything author-controlled (PR title/body, comments,
|
|
9
|
+
# the PR checkout, a SPEC_CLI persona file, forge.yaml paths.root) as data.
|
|
10
|
+
#
|
|
11
|
+
# Usage: review-prepare.sh --pr N --repo owner/name [--merge-issue N] [--thorough true|false] [--out FILE] [--out-dir DIR]
|
|
12
|
+
# [--checkout-pr]
|
|
13
|
+
# --pr PR number (numeric, required) --repo owner/name (required)
|
|
14
|
+
# --merge-issue issue the PR closes, from --issue (numeric; non-numeric is ignored)
|
|
15
|
+
# --thorough true restores the full-union roster --out write the context JSON here (default: stdout)
|
|
16
|
+
# --out-dir directory for per-domain diff slices (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-<pr>)
|
|
17
|
+
# --checkout-pr `git checkout -f --detach <review sha>` in the CURRENT repo (must be a throwaway review checkout, never the
|
|
18
|
+
# main checkout); the recorded original ref is restored by restore_ref on every exit path
|
|
19
|
+
# Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
|
|
20
|
+
# REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5).
|
|
21
|
+
# Exit: 0 context emitted (status OK) | 3 context emitted with status BLOCKED (the caller must stop, never skip) | 2 usage error
|
|
22
|
+
#
|
|
23
|
+
# Moved from the spec and still in the spec (the caller keeps running them): Phase 2 lint/typecheck/test/build EXECUTION (long-running,
|
|
24
|
+
# needs the throwaway worktree), Phase 3B.5 provenance trust escalation, the FORGE:REVIEW_TIER comment and the carry-record post.
|
|
25
|
+
#
|
|
26
|
+
# Behavior changes vs the prose fences (intentional, documented in the issue): (1) an empty trusted typescript.build / python.format on a
|
|
27
|
+
# full-build lane (staging->main or milestone->staging) is BLOCKED, not SKIPPED; (2) forge.yaml is loaded BEFORE the roster, so
|
|
28
|
+
# review.domains.scraping gates scraping on the first pass; (3) an absent yq key is empty, never the literal string "null"; (4) the
|
|
29
|
+
# contract comment is read through scripts/trusted-comments.sh; (5) the ISSUE_NUM extraction is numeric-validated (lib/pr-issue-number.sh).
|
|
30
|
+
#
|
|
31
|
+
# Context JSON on stdout / --out (schema_version 1):
|
|
32
|
+
# schema_version 1
|
|
33
|
+
# status "OK" | "BLOCKED"; blockers: [string] (non-empty iff BLOCKED)
|
|
34
|
+
# pr {number, repo, title, author, head_ref, base_ref, review_sha, review_sha_short, additions, deletions,
|
|
35
|
+
# changed_files, first_time_contributor}
|
|
36
|
+
# lane {is_staging_to_main, is_milestone_to_staging, requires_full_build, thorough}
|
|
37
|
+
# merge_health {mergeable: MERGEABLE|CONFLICTING|UNKNOWN, state: CLEAN|DIRTY|BLOCKED|UNSTABLE|UNKNOWN, retries}
|
|
38
|
+
# classification {services: [..], domains: [..]}
|
|
39
|
+
# phase2 {build_config: {typescript_build, python_format, status}, findings: [string]} (2D env, 2E secrets, 2F sql)
|
|
40
|
+
# broken_assumptions [string] (Phase 2.5 verify-*.sh BLOCKING/WARNING lines and the sibling-workflow drift check)
|
|
41
|
+
# risk_signals [string]; churn_context: string; churn_escalation: bool; danger_zone: bool
|
|
42
|
+
# scores {AUTH, BILLING, CONCURRENCY, DATABASE, INFRA, SECURITY, SCRAPING, FRONTEND, API}
|
|
43
|
+
# contract {issue: N|null, flags: string}
|
|
44
|
+
# roster {tier, reason, agents: [canonical marker domains]} (the effective roster; [] for BASE_SYNC_ONLY)
|
|
45
|
+
# delta {mode: FULL|DELTA|BASE_SYNC_ONLY, from, carried_from, full_rounds, files: [string]}
|
|
46
|
+
# config {project_name, project_context, domains: {auth, billing, ...}, scraping_configured, repo_path}
|
|
47
|
+
# templates {source, base, monolithic_catalog}
|
|
48
|
+
# index_slices {auth, billing, database, api, frontend, infrastructure}: "Domain files (...): ..." strings
|
|
49
|
+
# slices {<canonical domain>: {paths: [string], diff_file: path}} (diffs live in files, never inline; capped at 100K chars)
|
|
50
|
+
# full_files [string] the whole PR file list (context under DELTA)
|
|
51
|
+
# Bash 3.2 portable (no mapfile, associative arrays, grep -P).
|
|
52
|
+
|
|
53
|
+
set -u
|
|
54
|
+
|
|
55
|
+
PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"
|
|
56
|
+
while [ $# -gt 0 ]; do
|
|
57
|
+
case "$1" in
|
|
58
|
+
--pr|--repo|--merge-issue|--thorough|--out|--out-dir)
|
|
59
|
+
[ $# -ge 2 ] || { echo "review-prepare: $1 requires a value" >&2; exit 2; } ;;
|
|
60
|
+
esac
|
|
61
|
+
case "$1" in
|
|
62
|
+
--pr) PR_NUMBER="${2:-}"; shift 2 ;;
|
|
63
|
+
--repo) REPO="${2:-}"; shift 2 ;;
|
|
64
|
+
--merge-issue) MERGE_ISSUE="${2:-}"; shift 2 ;;
|
|
65
|
+
--thorough) THOROUGH="${2:-false}"; shift 2 ;;
|
|
66
|
+
--out) OUT="${2:-}"; shift 2 ;;
|
|
67
|
+
--out-dir) OUT_DIR="${2:-}"; shift 2 ;;
|
|
68
|
+
--checkout-pr) CHECKOUT_PR="true"; shift ;;
|
|
69
|
+
*) echo "review-prepare: unknown argument: $1" >&2; exit 2 ;;
|
|
70
|
+
esac
|
|
71
|
+
done
|
|
72
|
+
case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-prepare: --pr must be numeric" >&2; exit 2 ;; esac
|
|
73
|
+
case "$REPO" in */*) ;; *) echo "review-prepare: --repo must be owner/name" >&2; exit 2 ;; esac
|
|
74
|
+
case "$REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/) echo "review-prepare: invalid --repo" >&2; exit 2 ;; esac
|
|
75
|
+
case "$MERGE_ISSUE" in *[!0-9]*) MERGE_ISSUE="" ;; esac
|
|
76
|
+
[ "$THOROUGH" = "true" ] || THOROUGH="false"
|
|
77
|
+
|
|
78
|
+
# --- trusted helper resolution (install roots only; never cwd) ---
|
|
79
|
+
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() { :; }; }
|
|
80
|
+
|
|
81
|
+
ROSTER_SCRIPT=$(forge_resolve review-roster.sh)
|
|
82
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
83
|
+
REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
|
|
84
|
+
CODE_INDEX_SCRIPT=$(forge_resolve code-index.sh)
|
|
85
|
+
_l=$(forge_resolve lib/pr-issue-number.sh); if [ -n "$_l" ]; then . "$_l"; else pr_issue_number() { return 0; }; fi
|
|
86
|
+
_l=$(forge_resolve lib/review-template-resolve.sh); if [ -n "$_l" ]; then . "$_l"; else review_template_resolve() { TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""; }; fi
|
|
87
|
+
_l=$(forge_resolve lib/review-delta-mode.sh); if [ -n "$_l" ]; then . "$_l"; else review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; fi
|
|
88
|
+
|
|
89
|
+
# --- state, scratch and the ref-restore guard ---
|
|
90
|
+
BLOCKERS=""
|
|
91
|
+
block() { BLOCKERS="${BLOCKERS}${1}"$'\n'; }
|
|
92
|
+
[ -n "$OUT_DIR" ] || OUT_DIR="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-${PR_NUMBER}"
|
|
93
|
+
mkdir -p "$OUT_DIR" 2>/dev/null || { echo "review-prepare: cannot create $OUT_DIR" >&2; exit 2; }
|
|
94
|
+
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/review-prepare.XXXXXX") || exit 2
|
|
95
|
+
ORIG_REF=""; CHECKED_OUT=""
|
|
96
|
+
# restore_ref: put the checkout back exactly where it was. -f so a dirty review checkout can never wedge the restore.
|
|
97
|
+
restore_ref() {
|
|
98
|
+
if [ -n "$CHECKED_OUT" ] && [ -n "$ORIG_REF" ]; then
|
|
99
|
+
git checkout -f "$ORIG_REF" >/dev/null 2>&1 || echo "review-prepare: WARNING could not restore $ORIG_REF" >&2
|
|
100
|
+
fi
|
|
101
|
+
CHECKED_OUT=""
|
|
102
|
+
}
|
|
103
|
+
cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
|
|
104
|
+
trap cleanup EXIT
|
|
105
|
+
|
|
106
|
+
# --- Phase 1A: fetch ---
|
|
107
|
+
PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
|
|
108
|
+
printf '%s' "$PR_INFO" | jq -e . >/dev/null 2>&1 || PR_INFO=""
|
|
109
|
+
if [ -z "$PR_INFO" ]; then block "gh pr view failed for #${PR_NUMBER}"; fi
|
|
110
|
+
pj() { printf '%s' "$PR_INFO" | jq -r "$1 // empty" 2>/dev/null; }
|
|
111
|
+
TITLE=$(pj .title); PR_AUTHOR=$(pj .author.login); HEAD=$(pj .headRefName); BASE=$(pj .baseRefName)
|
|
112
|
+
REVIEW_SHA=$(pj .headRefOid); REVIEW_SHA_SHORT=$(printf '%s' "$REVIEW_SHA" | cut -c1-7)
|
|
113
|
+
ADDITIONS=$(pj .additions); DELETIONS=$(pj .deletions); NFILES=$(pj .changedFiles)
|
|
114
|
+
case "$ADDITIONS" in ''|*[!0-9]*) ADDITIONS=0 ;; esac; case "$DELETIONS" in ''|*[!0-9]*) DELETIONS=0 ;; esac
|
|
115
|
+
case "$NFILES" in ''|*[!0-9]*) NFILES="" ;; esac
|
|
116
|
+
LINES=$((ADDITIONS + DELETIONS))
|
|
117
|
+
[ -z "$PR_INFO" ] || [ -n "$REVIEW_SHA" ] || block "could not read the PR head SHA"
|
|
118
|
+
|
|
119
|
+
FILES=""; DIFF=""
|
|
120
|
+
if [ -z "$BLOCKERS" ]; then
|
|
121
|
+
FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || block "gh pr diff --name-only failed"
|
|
122
|
+
DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || block "gh pr diff failed"
|
|
123
|
+
[ -n "$FILES" ] || [ -n "$BLOCKERS" ] || block "PR #${PR_NUMBER} has an empty file list"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# Mergeability: GitHub computes it asynchronously; retry up to 3x on UNKNOWN.
|
|
127
|
+
merge_health() { gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\(.mergeable)|\(.mergeStateStatus)"' 2>/dev/null || echo "UNKNOWN|UNKNOWN"; }
|
|
128
|
+
MH=$(merge_health); MERGE_HEALTH=${MH%%|*}; MERGE_HEALTH_STATE=${MH##*|}; MERGE_RETRY=0
|
|
129
|
+
while [ "$MERGE_HEALTH" = "UNKNOWN" ] && [ "$MERGE_RETRY" -lt 3 ]; do
|
|
130
|
+
MERGE_RETRY=$((MERGE_RETRY + 1)); sleep "${REVIEW_PREPARE_SLEEP:-5}"
|
|
131
|
+
MH=$(merge_health); MERGE_HEALTH=${MH%%|*}; MERGE_HEALTH_STATE=${MH##*|}
|
|
132
|
+
done
|
|
133
|
+
case "$MERGE_HEALTH" in MERGEABLE|CONFLICTING|UNKNOWN) ;; *) MERGE_HEALTH="UNKNOWN" ;; esac
|
|
134
|
+
case "$MERGE_HEALTH_STATE" in CLEAN|DIRTY|BLOCKED|UNSTABLE|HAS_HOOKS|BEHIND|DRAFT|UNKNOWN) ;; *) MERGE_HEALTH_STATE="UNKNOWN" ;; esac
|
|
135
|
+
|
|
136
|
+
# First-time contributor (consumed by Phase 9): no prior merged PR by this author.
|
|
137
|
+
IS_FIRST_TIME_CONTRIBUTOR="false"
|
|
138
|
+
if [ -n "$PR_AUTHOR" ]; then
|
|
139
|
+
PRIOR_MERGED_COUNT=$(gh pr list -R "$REPO" --author "$PR_AUTHOR" --state merged --limit 1 --json number --jq 'length' 2>/dev/null || echo "1")
|
|
140
|
+
case "$PRIOR_MERGED_COUNT" in ''|*[!0-9]*) PRIOR_MERGED_COUNT=1 ;; esac
|
|
141
|
+
[ "$PRIOR_MERGED_COUNT" -eq 0 ] && IS_FIRST_TIME_CONTRIBUTOR="true"
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# Lane (the router already diverted staging->main; these still gate the full-build check and the roster).
|
|
145
|
+
IS_STAGING_TO_MAIN="false"; [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ] && IS_STAGING_TO_MAIN="true"
|
|
146
|
+
IS_MILESTONE_TO_STAGING="false"; case "$HEAD" in milestone/*) [ "$BASE" = "staging" ] && IS_MILESTONE_TO_STAGING="true" ;; esac
|
|
147
|
+
REQUIRES_FULL_BUILD="false"
|
|
148
|
+
{ [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && REQUIRES_FULL_BUILD="true"
|
|
149
|
+
|
|
150
|
+
# --- optional PR checkout (throwaway review checkout only) ---
|
|
151
|
+
if [ "$CHECKOUT_PR" = "true" ] && [ -z "$BLOCKERS" ]; then
|
|
152
|
+
ORIG_REF=$(git symbolic-ref -q --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || echo "")
|
|
153
|
+
git fetch origin "pull/${PR_NUMBER}/head" >/dev/null 2>&1
|
|
154
|
+
if git checkout -f --detach "$REVIEW_SHA" >/dev/null 2>&1; then CHECKED_OUT="1"; else block "could not check out review SHA ${REVIEW_SHA_SHORT}"; fi
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# --- config load (BEFORE scoring and the roster) ---
|
|
158
|
+
TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
|
|
159
|
+
_gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "")
|
|
160
|
+
MAIN_ROOT=""; [ -n "$_gcd" ] && MAIN_ROOT=$(cd "$_gcd/.." 2>/dev/null && pwd || echo "")
|
|
161
|
+
CONFIG_FILE="${FORGE_CONFIG:-}"
|
|
162
|
+
if [ -z "$CONFIG_FILE" ]; then
|
|
163
|
+
for _c in "$MAIN_ROOT/forge.yaml" "$TOP/forge.yaml"; do [ -f "$_c" ] && { CONFIG_FILE="$_c"; break; }; done
|
|
164
|
+
fi
|
|
165
|
+
CONFIG_READABLE="false"; [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ] && CONFIG_READABLE="true"
|
|
166
|
+
cfg() { [ "$CONFIG_READABLE" = "true" ] || return 0; yq "$1 // \"\"" "$CONFIG_FILE" 2>/dev/null || true; }
|
|
167
|
+
PROJECT_NAME=$(cfg .project.name); [ -n "$PROJECT_NAME" ] || PROJECT_NAME="this project"
|
|
168
|
+
PROJECT_CONTEXT=$(cfg .review.context)
|
|
169
|
+
DOMAIN_CONTEXT_AUTH=$(cfg .review.domains.auth); DOMAIN_CONTEXT_BILLING=$(cfg .review.domains.billing)
|
|
170
|
+
DOMAIN_CONTEXT_CONCURRENCY=$(cfg .review.domains.concurrency); DOMAIN_CONTEXT_INFRA=$(cfg .review.domains.infra)
|
|
171
|
+
DOMAIN_CONTEXT_DATABASE=$(cfg .review.domains.database); DOMAIN_CONTEXT_FRONTEND=$(cfg .review.domains.frontend)
|
|
172
|
+
DOMAIN_CONTEXT_SECURITY=$(cfg .review.domains.security); DOMAIN_CONTEXT_API=$(cfg .review.domains.api)
|
|
173
|
+
DOMAIN_CONTEXT_SCRAPING=$(cfg .review.domains.scraping) # also gates spawning the scraping agent
|
|
174
|
+
REPO_PATH=$(cfg .paths.root); [ -n "$REPO_PATH" ] || REPO_PATH="${TOP:-$PWD}" # data only: the index target, never a script source
|
|
175
|
+
|
|
176
|
+
# --- Phase 1B: classify ---
|
|
177
|
+
SERVICES=""; DOMAINS=""
|
|
178
|
+
svc() { printf '%s\n' "$FILES" | grep -qE "$2" && SERVICES="$SERVICES $1"; }
|
|
179
|
+
dom_f() { printf '%s\n' "$FILES" | grep -qE "$2" && DOMAINS="$DOMAINS $1"; }
|
|
180
|
+
dom_d() { printf '%s\n' "$DIFF" | grep -qE "$2" && DOMAINS="$DOMAINS $1"; }
|
|
181
|
+
svc API "(^services/api/|/api/|/backend/)"; svc WORKER "(^services/worker/|/worker/|/jobs/|/tasks/)"
|
|
182
|
+
svc WEB "(^web/|/frontend/|/app/|\.tsx?$|\.jsx?$)"; svc SHARED "(^shared/|/shared/|/common/|/lib/)"
|
|
183
|
+
svc INFRA "^(docker|infra/|\.github|Makefile|traefik|k8s/|terraform/)"
|
|
184
|
+
dom_d AUTH "get_current_user|jwt|oauth|login|logout|require_auth|authenticated_user|x.forwarded.for|x_forwarded_for|forwarded_for|rate.limit.*ip|ip.*rate.limit|algorithm.*HS256|algorithm.*RS256|NEXTAUTH_SECRET|JWT_SECRET"
|
|
185
|
+
dom_d BILLING "credit|balance|debit|reconcil|pricing|charge|refund|stripe|subscription|payment"
|
|
186
|
+
dom_d SCRAPING "scrape|tier.*escalat|anti_bot|stealth|playwright.*scrape|scrape.*playwright|playbook_min_tier|browser.*pool|proxy.*scrape|web.*scrape"
|
|
187
|
+
dom_d CONCURRENCY "FOR UPDATE|atomic|transaction|pipeline|MULTI|distributed_lock|acquire_lock|reserved_by|promo.*claim|voucher.*redeem"
|
|
188
|
+
dom_f DATABASE "migration|\.sql$"
|
|
189
|
+
dom_d DB_CONFIG "create_async_engine|AsyncSession|connect_args|pool_size|prepared_statement|engine_from_config|sessionmaker"
|
|
190
|
+
dom_f API_DESIGN "router|routes"
|
|
191
|
+
|
|
192
|
+
# --- Phase 2: read-only checks + the trusted full-build config gate ---
|
|
193
|
+
P2_FINDINGS=""
|
|
194
|
+
p2() { P2_FINDINGS="${P2_FINDINGS}${1}"$'\n'; }
|
|
195
|
+
HAS_TS=$(printf '%s\n' "$FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
|
|
196
|
+
HAS_PY=$(printf '%s\n' "$FILES" | grep -E '\.py$' | head -1)
|
|
197
|
+
TS_BUILD=$(cfg .verification.commands.typescript.build); PY_FORMAT=$(cfg .verification.commands.python.format)
|
|
198
|
+
BUILD_CONFIG_STATUS="not_required"
|
|
199
|
+
if [ "$REQUIRES_FULL_BUILD" = "true" ]; then
|
|
200
|
+
BUILD_CONFIG_STATUS="ok"
|
|
201
|
+
if [ "$CONFIG_READABLE" != "true" ]; then
|
|
202
|
+
BUILD_CONFIG_STATUS="blocked"; block "full-build lane but no readable trusted forge.yaml (checked FORGE_CONFIG, the main checkout and the git top-level)"
|
|
203
|
+
else
|
|
204
|
+
if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: typescript.build is empty in verification.commands on a full-build lane (typecheck alone misses prerender failures)"; fi
|
|
205
|
+
if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: python.format is empty in verification.commands on a full-build lane"; fi
|
|
206
|
+
fi
|
|
207
|
+
fi
|
|
208
|
+
# 2D env vars, 2E secrets (CRITICAL), 2F SQL, over the diff text
|
|
209
|
+
while IFS= read -r _l; do [ -n "$_l" ] && p2 "2D env: $_l"; done <<EOF2
|
|
210
|
+
$(printf '%s\n' "$DIFF" | grep -E "os\.getenv|os\.environ|process\.env" | head -30)
|
|
211
|
+
EOF2
|
|
212
|
+
while IFS= read -r _l; do [ -n "$_l" ] && p2 "2E secret?: $_l"; done <<EOF2
|
|
213
|
+
$(printf '%s\n' "$DIFF" | grep -iE "(api[_-]?key|secret[_-]?key|password|token|credential|private[_-]?key)" | grep -vE "(#|//|\.example|placeholder|PLACEHOLDER|YOUR_|<|>)" | head -20)
|
|
214
|
+
EOF2
|
|
215
|
+
while IFS= read -r _l; do [ -n "$_l" ] && p2 "2E secret?: high-entropy literal ${_l}"; done <<EOF2
|
|
216
|
+
$(printf '%s\n' "$DIFF" | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10)
|
|
217
|
+
EOF2
|
|
218
|
+
file_at_head() { git show "${REVIEW_SHA}:${1}" 2>/dev/null || cat "$1" 2>/dev/null || true; }
|
|
219
|
+
for f in $(printf '%s\n' "$FILES" | grep '\.sql$'); do
|
|
220
|
+
_c=$(file_at_head "$f"); [ -n "$_c" ] || continue
|
|
221
|
+
printf '%s\n' "$_c" | grep -E "FOR UPDATE" | grep -qE "(SUM|COUNT|AVG|MIN|MAX)\s*\(" && p2 "2F ERROR: FOR UPDATE with aggregate in $f"
|
|
222
|
+
printf '%s\n' "$_c" | grep -qE "DROP (TABLE|COLUMN|INDEX)" && ! printf '%s\n' "$_c" | grep -qE "IF EXISTS" && p2 "2F WARNING: DROP without IF EXISTS in $f"
|
|
223
|
+
printf '%s\n' "$_c" | grep -qE "ALTER TABLE.*ADD COLUMN.*NOT NULL" && ! printf '%s\n' "$_c" | grep -qE "DEFAULT" && p2 "2F WARNING: NOT NULL without DEFAULT in $f"
|
|
224
|
+
done
|
|
225
|
+
|
|
226
|
+
# --- Phase 2.5: integration assumptions (verify-*.sh via the trusted resolver) ---
|
|
227
|
+
ASSUMPTIONS=""
|
|
228
|
+
asm() { ASSUMPTIONS="${ASSUMPTIONS}${1}"$'\n'; }
|
|
229
|
+
printf '%s\n' "$FILES" > "$TMP_DIR/changed.txt"; printf '%s\n' "$DIFF" > "$TMP_DIR/diff.txt"
|
|
230
|
+
LAYOUT_PAGES=$(cfg .review.layout.pages); LAYOUT_ROUTERS=$(cfg .review.layout.api_routers_dir); LAYOUT_MAIN=$(cfg .review.layout.api_main); LAYOUT_MW=$(cfg .review.layout.api_middleware_dir)
|
|
231
|
+
[ -n "$LAYOUT_PAGES" ] && export FORGE_PAGES_ROOT="$LAYOUT_PAGES"; [ -n "$LAYOUT_ROUTERS" ] && export FORGE_API_ROUTERS_DIR="$LAYOUT_ROUTERS"
|
|
232
|
+
[ -n "$LAYOUT_MAIN" ] && export FORGE_API_MAIN="$LAYOUT_MAIN"; [ -n "$LAYOUT_MW" ] && export FORGE_API_MIDDLEWARE_DIR="$LAYOUT_MW"
|
|
233
|
+
FORGE_INTERNAL_PATTERNS=""
|
|
234
|
+
if [ "$CONFIG_READABLE" = "true" ]; then
|
|
235
|
+
FORGE_INTERNAL_PATTERNS=$(grep -A 999 'internal_service_patterns:' "$CONFIG_FILE" 2>/dev/null | grep -E '^\s*-\s+' | sed 's/^\s*-\s*//' | tr -d "\"'" | awk 'NR>1{printf "|"}{printf "%s", $0}END{print ""}')
|
|
236
|
+
fi
|
|
237
|
+
export FORGE_INTERNAL_PATTERNS
|
|
238
|
+
run_verify() { # <script name> <args...>; BLOCKING/WARNING lines become broken assumptions
|
|
239
|
+
local name="$1" path out; shift
|
|
240
|
+
path=$(forge_resolve "$name")
|
|
241
|
+
[ -n "$path" ] || { asm "WARNING: ${name} unresolved (trusted install root not found); integration check not run"; return 0; }
|
|
242
|
+
out=$(bash "$path" "$@" 2>&1) || true
|
|
243
|
+
while IFS= read -r _l; do case "$_l" in BLOCKING:*|WARNING:*) asm "${name}: ${_l}" ;; esac; done <<EOF2
|
|
244
|
+
$out
|
|
245
|
+
EOF2
|
|
246
|
+
}
|
|
247
|
+
run_verify verify-route-registration.sh "$TMP_DIR/changed.txt" "."
|
|
248
|
+
run_verify verify-env-vars.sh "$TMP_DIR/diff.txt" "."
|
|
249
|
+
run_verify verify-host-headers.sh "$TMP_DIR/changed.txt" "."
|
|
250
|
+
run_verify verify-sops-chain.sh "$TMP_DIR/diff.txt" "$TMP_DIR/changed.txt" "."
|
|
251
|
+
# Sibling workflow drift (always for staging->main; otherwise when a workflow file changed)
|
|
252
|
+
if { [ "$IS_STAGING_TO_MAIN" = "true" ] || printf '%s\n' "$FILES" | grep -qE "^\.github/workflows/.*\.yml$"; } && [ -f .github/workflows/ci.yml ] && [ -f .github/workflows/deploy-production.yml ]; then
|
|
253
|
+
for JOB in test-api test-web; do
|
|
254
|
+
job_env() { awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$1" 2>/dev/null | grep "PYTHONPATH" | sed 's/^ *//' | sort; }
|
|
255
|
+
CI_PYPATH=$(job_env .github/workflows/ci.yml); DEPLOY_PYPATH=$(job_env .github/workflows/deploy-production.yml)
|
|
256
|
+
[ "$CI_PYPATH" = "$DEPLOY_PYPATH" ] || asm "BLOCKING: PYTHONPATH differs between ci.yml and deploy-production.yml for job '${JOB}'"
|
|
257
|
+
done
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
# --- Phase 3A: risk signals + churn ---
|
|
261
|
+
RISK=""
|
|
262
|
+
sig_d() { printf '%s\n' "$DIFF" | grep -qE "$2" && RISK="$RISK $1"; }
|
|
263
|
+
sig_d UNTRUSTED_INPUT_PROCESSING "subprocess|exec|eval|system\(|popen|heredoc"; sig_d SHELL_SCRIPT "\.sh$|bash|shell|cron"
|
|
264
|
+
sig_d FINANCIAL "credit|balance|debit|charge|refund|stripe"
|
|
265
|
+
sig_d AUTH_SENSITIVE "jwt|oauth|password|token|secret|x.forwarded.for|x_forwarded_for|forwarded_for|rate.limit.*ip|ip.*rate.limit|algorithm.*HS256|algorithm.*RS256|NEXTAUTH_SECRET|JWT_SECRET|get_current_user|require_auth"
|
|
266
|
+
sig_d DATABASE_MUTATION "\.sql$|migration|DROP|ALTER|DELETE FROM"; sig_d INFRASTRUCTURE "docker|deploy|traefik|nginx|\.yml.*service"
|
|
267
|
+
sig_d DATABASE_CONTAINER "docker-compose.*postgres|docker-compose.*redis|postgres.*command:|redis.*command:|image:.*postgres|image:.*redis"
|
|
268
|
+
sig_d DB_CONFIG "create_async_engine|AsyncSession|connect_args|pool_size|prepared_statement|engine_from_config|sessionmaker"
|
|
269
|
+
sig_d CODE_EXECUTION "subprocess|os\.system|eval\(|exec\(|pickle|yaml\.load[^_]"
|
|
270
|
+
printf '%s\n' "$FILES" | grep -qE "^sdk/|openapi.*\.json$|openapi-versions/" && RISK="$RISK SDK_OPENAPI"
|
|
271
|
+
|
|
272
|
+
CHURN_WINDOW="90 days ago" # must match architect.md Phase A5
|
|
273
|
+
CHURN_CONTEXT=""
|
|
274
|
+
DANGER_ZONES_INDEX="${HOME}/.forge/index/danger-zones.json"; DZ_DATA=""
|
|
275
|
+
[ -f "$DANGER_ZONES_INDEX" ] && DZ_DATA=$(cat "$DANGER_ZONES_INDEX" 2>/dev/null || true)
|
|
276
|
+
while IFS= read -r FILE; do
|
|
277
|
+
[ -z "$FILE" ] && continue
|
|
278
|
+
COMMITS=$(git log --oneline --since="$CHURN_WINDOW" -- "$FILE" 2>/dev/null | wc -l | tr -d ' ')
|
|
279
|
+
COMMIT_TAG=""; [ "${COMMITS:-0}" -ge 15 ] && COMMIT_TAG=" — HOT (${COMMITS} commits/90d)"
|
|
280
|
+
FINDING_TAG=""
|
|
281
|
+
if [ -n "$DZ_DATA" ]; then
|
|
282
|
+
FINDING_COUNT=$(printf '%s' "$DZ_DATA" | jq -r --arg f "$FILE" '.files[$f].findingCount90d // 0' 2>/dev/null || echo 0)
|
|
283
|
+
case "$FINDING_COUNT" in ''|*[!0-9]*) FINDING_COUNT=0 ;; esac
|
|
284
|
+
[ "$FINDING_COUNT" -ge 3 ] && FINDING_TAG=" — HOT-FINDINGS (${FINDING_COUNT} findings/90d)"
|
|
285
|
+
fi
|
|
286
|
+
if [ -n "$COMMIT_TAG" ] || [ -n "$FINDING_TAG" ]; then CHURN_CONTEXT="${CHURN_CONTEXT}${FILE}${COMMIT_TAG}${FINDING_TAG}"$'\n'; fi
|
|
287
|
+
done <<< "$FILES"
|
|
288
|
+
[ -n "$CHURN_CONTEXT" ] || CHURN_CONTEXT="No hot-spot files detected (all changed files under 15 commits and under 3 findings in the last 90 days)."
|
|
289
|
+
CHURN_ESCALATION=false; printf '%s\n' "$CHURN_CONTEXT" | grep -q "HOT" && CHURN_ESCALATION=true
|
|
290
|
+
DANGER=false; printf '%s\n' "$CHURN_CONTEXT" | grep -q "HOT-FINDINGS" && DANGER=true
|
|
291
|
+
|
|
292
|
+
# --- Phase 3B step 1: domain scores ---
|
|
293
|
+
SCORE_AUTH=0; SCORE_BILLING=0; SCORE_CONCURRENCY=0; SCORE_DATABASE=0; SCORE_INFRA=0; SCORE_SECURITY=1; SCORE_SCRAPING=0; SCORE_FRONTEND=0; SCORE_API=0
|
|
294
|
+
sc_d() { printf '%s\n' "$DIFF" | grep -qE "$2" && eval "$1=\$(( $1 + $3 ))"; }
|
|
295
|
+
sc_f() { printf '%s\n' "$FILES" | grep -qE "$2" && eval "$1=\$(( $1 + $3 ))"; }
|
|
296
|
+
sc_d SCORE_AUTH "jwt|oauth|login|logout|password|NEXTAUTH_SECRET|JWT_SECRET|auth_token|access_token|refresh_token" 3
|
|
297
|
+
sc_d SCORE_AUTH "get_current_user|Depends\(get_|x.forwarded.for|forwarded_for|rate.limit.*ip|ip.*rate.limit" 2
|
|
298
|
+
sc_d SCORE_AUTH "algorithm.*HS256|algorithm.*RS256|admin.proxy" 2
|
|
299
|
+
sc_d SCORE_BILLING "credit|balance|debit|charge|refund|stripe|subscription" 3
|
|
300
|
+
sc_d SCORE_BILLING "pricing|tier_cost|reconcil" 2
|
|
301
|
+
sc_d SCORE_CONCURRENCY "FOR UPDATE|atomic|transaction|pipeline|MULTI|distributed_lock|acquire_lock" 3
|
|
302
|
+
sc_d SCORE_CONCURRENCY "reserved_by|promo.*claim|voucher.*redeem" 2
|
|
303
|
+
sc_f SCORE_DATABASE "migration|\.sql$" 3
|
|
304
|
+
sc_d SCORE_DATABASE "DROP|ALTER|DELETE FROM" 2
|
|
305
|
+
sc_d SCORE_DATABASE "create_async_engine|AsyncSession|pool_size|prepared_statement|sessionmaker" 2
|
|
306
|
+
sc_d SCORE_INFRA "docker-compose.*postgres|docker-compose.*redis|postgres.*command:|redis.*command:" 3
|
|
307
|
+
sc_d SCORE_INFRA "docker|deploy|traefik|nginx" 1
|
|
308
|
+
sc_f SCORE_INFRA "^\.github/workflows/" 2
|
|
309
|
+
sc_f SCORE_INFRA "Dockerfile|docker-compose" 2
|
|
310
|
+
sc_d SCORE_SECURITY "subprocess|exec|eval|system\(|popen" 3
|
|
311
|
+
sc_d SCORE_SECURITY "os\.system|eval\(|exec\(|pickle|yaml\.load[^_]" 3
|
|
312
|
+
sc_d SCORE_SECURITY "\.sh$|bash|shell|cron" 2
|
|
313
|
+
sc_d SCORE_SCRAPING "scrape|tier.*escalat|anti_bot|stealth|playwright.*scrape|scrape.*playwright|playbook_min_tier|browser.*pool|web.*scrape" 3
|
|
314
|
+
sc_f SCORE_FRONTEND "^web/src/" 1
|
|
315
|
+
sc_f SCORE_FRONTEND "^web/src/app/|^web/src/components/" 2
|
|
316
|
+
sc_f SCORE_API "router|routes" 2
|
|
317
|
+
sc_f SCORE_API "^sdk/|openapi.*\.json$|openapi-versions/" 3
|
|
318
|
+
|
|
319
|
+
# --- Phase 3B step 2: contract risk flags (trusted authors only) ---
|
|
320
|
+
CONTRACT_RISK_FLAGS=""
|
|
321
|
+
ISSUE_NUM=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body --jq '.body // ""' 2>/dev/null | pr_issue_number)
|
|
322
|
+
case "$ISSUE_NUM" in ''|*[!0-9]*) ISSUE_NUM="" ;; esac
|
|
323
|
+
if [ -n "$ISSUE_NUM" ]; then
|
|
324
|
+
CONTRACT_BODY=""; CONTRACT_OK="false"
|
|
325
|
+
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
326
|
+
# Each stage is checked on its own: in a pipeline `jq -r .` on empty stdin exits 0, so a failed read looked like success.
|
|
327
|
+
# A failed or empty comment read, or a failing trusted-comments run, stays CONTRACT_OK=false (widen, never narrow).
|
|
328
|
+
# A successful read with no trusted contract comment is the deliberate "no contract" case (empty body, no flags).
|
|
329
|
+
if _cc=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUM}/comments" 2>/dev/null) && [ -n "$_cc" ] \
|
|
330
|
+
&& _cb=$(printf '%s' "$_cc" | bash "$TRUSTED_SCRIPT" bodies 'FORGE:CONTRACT' 2>/dev/null); then
|
|
331
|
+
CONTRACT_OK="true"
|
|
332
|
+
[ -z "$_cb" ] || CONTRACT_BODY=$(printf '%s\n' "$_cb" | head -n 1 | jq -r . 2>/dev/null) || CONTRACT_OK="false"
|
|
333
|
+
fi
|
|
334
|
+
fi
|
|
335
|
+
if [ "$CONTRACT_OK" != "true" ]; then
|
|
336
|
+
CONTRACT_RISK_FLAGS="HIGH_RISK" # cannot read the contract trust-filtered: widen, never narrow
|
|
337
|
+
else
|
|
338
|
+
printf '%s\n' "$CONTRACT_BODY" | grep -qiE "\*\*Risk\*\*.*HIGH|\*\*Risk\*\*.*CRITICAL|Risk.*:\s*(HIGH|CRITICAL)" && CONTRACT_RISK_FLAGS="HIGH_RISK"
|
|
339
|
+
printf '%s\n' "$CONTRACT_BODY" | grep -qiE "auth|session|jwt" && CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS} CONTRACT_AUTH"
|
|
340
|
+
printf '%s\n' "$CONTRACT_BODY" | grep -qiE "billing|credit|payment" && CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS} CONTRACT_BILLING"
|
|
341
|
+
printf '%s\n' "$CONTRACT_BODY" | grep -qiE "migration|schema|database" && CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS} CONTRACT_DATABASE"
|
|
342
|
+
fi
|
|
343
|
+
fi
|
|
344
|
+
CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS# }"
|
|
345
|
+
|
|
346
|
+
# --- Phase 3B step 4: roster (review-roster.sh; unresolved or failing means the full union) ---
|
|
347
|
+
agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
|
|
348
|
+
FIX_PR=false; SRC_SEV=""; SRC_DOM=""
|
|
349
|
+
if [ -n "$MERGE_ISSUE" ]; then
|
|
350
|
+
gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels --jq '[.labels[].name] | any(. == "review-finding")' 2>/dev/null | grep -qx true && FIX_PR=true
|
|
351
|
+
if [ "$FIX_PR" = "true" ]; then
|
|
352
|
+
SRC_BODY=$(gh issue view "$MERGE_ISSUE" -R "$REPO" --json body --jq '.body' 2>/dev/null)
|
|
353
|
+
SRC_SEV=$(printf '%s\n' "$SRC_BODY" | sed -n 's/^\*\*Severity\*\*: *\([A-Za-z]*\).*/\1/p' | head -n 1)
|
|
354
|
+
SRC_DOM=$(printf '%s\n' "$SRC_BODY" | sed -n 's/^\*\*Agent\*\*: *//p' | head -n 1 | tr -c 'A-Za-z\n' ' ' | tr ' ' '\n' | grep -viE '^security$' | grep -v '^$' | head -n 1)
|
|
355
|
+
fi
|
|
356
|
+
fi
|
|
357
|
+
SC="AUTH=$SCORE_AUTH BILLING=$SCORE_BILLING CONCURRENCY=$SCORE_CONCURRENCY DATABASE=$SCORE_DATABASE INFRA=$SCORE_INFRA SECURITY=$SCORE_SECURITY SCRAPING=$SCORE_SCRAPING FRONTEND=$SCORE_FRONTEND API=$SCORE_API"
|
|
358
|
+
SPEC_CLI=false; [ -n "$TOP" ] && [ -f "$TOP/commands/review-pr-agents/spec-cli.md" ] && SPEC_CLI=true # existence only
|
|
359
|
+
SCRAPING_ON=false; [ -n "$DOMAIN_CONTEXT_SCRAPING" ] && SCRAPING_ON=true
|
|
360
|
+
DBC=false; printf '%s\n' "$DIFF" | grep -qE "docker-compose.*postgres|docker-compose.*redis|postgres.*command:|redis.*command:" && DBC=true
|
|
361
|
+
ROSTER_OUT=""
|
|
362
|
+
[ -n "$ROSTER_SCRIPT" ] && ROSTER_OUT=$(printf '%s\n' "$FILES" | bash "$ROSTER_SCRIPT" --files-file - --scores "$SC" --changed-lines "$LINES" --changed-files "$NFILES" \
|
|
363
|
+
--fix-pr "$FIX_PR" --source-severity "$SRC_SEV" --source-domain "$SRC_DOM" --contract-flags "$CONTRACT_RISK_FLAGS" --churn "$CHURN_ESCALATION" --danger-zone "$DANGER" \
|
|
364
|
+
--thorough "$THOROUGH" --milestone "$IS_MILESTONE_TO_STAGING" --scraping-configured "$SCRAPING_ON" --database-container "$DBC" --spec-cli "$SPEC_CLI" 2>/dev/null)
|
|
365
|
+
SELECTED_AGENTS=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^SELECTED_AGENTS=//p')
|
|
366
|
+
ROSTER_TIER=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^TIER=//p')
|
|
367
|
+
ROSTER_REASON=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^REASON=//p')
|
|
368
|
+
FULL_UNION="false"
|
|
369
|
+
if [ -z "$SELECTED_AGENTS" ]; then
|
|
370
|
+
FULL_UNION="true"; THOROUGH=true; ROSTER_TIER=full; ROSTER_REASON="review-roster.sh unavailable or failed: full union"
|
|
371
|
+
fi
|
|
372
|
+
if [ "$THOROUGH" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; then FULL_UNION="true"; fi
|
|
373
|
+
if [ "$FULL_UNION" = "true" ]; then
|
|
374
|
+
SELECTED_AGENTS="Security"
|
|
375
|
+
[ "$SCORE_AUTH" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Auth"
|
|
376
|
+
[ "$SCORE_BILLING" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Billing Concurrency"
|
|
377
|
+
[ "$SCORE_DATABASE" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Database"
|
|
378
|
+
[ "$SCORE_INFRA" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Infrastructure"
|
|
379
|
+
[ "$SCORE_SCRAPING" -gt 0 ] && [ "$SCRAPING_ON" = "true" ] && SELECTED_AGENTS="$SELECTED_AGENTS Scraping"
|
|
380
|
+
[ "$SCORE_FRONTEND" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Frontend"
|
|
381
|
+
[ "$SCORE_API" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS API"
|
|
382
|
+
[ "$SPEC_CLI" = "true" ] && printf '%s\n' "$FILES" | grep -qE "^(commands|bin)/" && SELECTED_AGENTS="$SELECTED_AGENTS spec-cli"
|
|
383
|
+
fi
|
|
384
|
+
# Roster canonicalization: one canonical marker domain per persona.
|
|
385
|
+
canonical_roster() { local out="" a d; for a in $1; do d=$(agent_domain "$a"); case " $out " in *" $d "*) ;; *) out="$out $d" ;; esac; done; printf '%s' "${out# }"; }
|
|
386
|
+
SELECTED_AGENTS=$(canonical_roster "$SELECTED_AGENTS")
|
|
387
|
+
|
|
388
|
+
# --- Phase 3C: review mode (FULL | DELTA | BASE_SYNC_ONLY) ---
|
|
389
|
+
BASE_BRANCH="$BASE"
|
|
390
|
+
COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
|
|
391
|
+
delta_roster() {
|
|
392
|
+
local rounds="${1}" full="${2}" files="${3}" out="security" root
|
|
393
|
+
root="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
394
|
+
case "$rounds" in ''|*[!0-9]*) printf '%s' "$full"; return 0 ;; esac
|
|
395
|
+
if [ "$rounds" -lt 2 ]; then printf '%s' "$full"; return 0; fi
|
|
396
|
+
printf '%s\n' "$files" | grep -qiE "auth|session|login|logout|jwt|oauth|token|user|permission|middleware" && out="$out auth"
|
|
397
|
+
printf '%s\n' "$files" | grep -qiE "billing|credit|payment|charge|refund|subscription|pricing|tier" && out="$out billing"
|
|
398
|
+
printf '%s\n' "$files" | grep -qiE "lock|queue|atomic|transaction|worker|job|task|celery|redis|claim|lease|engine-state" && out="$out concurrency"
|
|
399
|
+
printf '%s\n' "$files" | grep -qiE "migration|\.sql$|model|schema|db|database|alembic" && out="$out database"
|
|
400
|
+
printf '%s\n' "$files" | grep -qiE "docker|nginx|traefik|\.github|Makefile|deploy|infra" && out="$out infra"
|
|
401
|
+
printf '%s\n' "$files" | grep -qiE "scrape|crawler|stealth|browser.*pool|headless|anti.bot|detection.*keyword|captcha" && out="$out scraper"
|
|
402
|
+
printf '%s\n' "$files" | grep -qiE "^web/|\.tsx?$|\.jsx?$|component|page|layout|style|css" && out="$out frontend"
|
|
403
|
+
printf '%s\n' "$files" | grep -qiE "router|route|endpoint|openapi|sdk|api" && out="$out api"
|
|
404
|
+
printf '%s\n' "$files" | grep -qE "^(commands|bin)/" && [ -f "$root/commands/review-pr-agents/spec-cli.md" ] && out="$out spec-cli"
|
|
405
|
+
printf '%s' "$out"
|
|
406
|
+
}
|
|
407
|
+
REVIEW_MODE="FULL"; DELTA_FROM=""; CARRIED_FROM=""; FULL_ROUNDS=0; DELTA_FILES=""
|
|
408
|
+
if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ]; then
|
|
409
|
+
read -r RD_KIND RD_ARG RD_ROUNDS <<< "$(review_delta_mode)"
|
|
410
|
+
FULL_ROUNDS="${RD_ROUNDS:-0}"
|
|
411
|
+
case "$RD_KIND" in
|
|
412
|
+
DELTA) REVIEW_MODE="DELTA"; DELTA_FROM="$RD_ARG" ;;
|
|
413
|
+
BASE_SYNC_ONLY) REVIEW_MODE="BASE_SYNC_ONLY"; CARRIED_FROM="$RD_ARG" ;;
|
|
414
|
+
esac
|
|
415
|
+
fi
|
|
416
|
+
if [ "$REVIEW_MODE" = "DELTA" ]; then
|
|
417
|
+
DELTA_FILES=$(git -C "${TOP:-.}" -c core.quotepath=off diff --name-only --no-renames "${DELTA_FROM}" "${REVIEW_SHA}" 2>/dev/null) || DELTA_FILES=""
|
|
418
|
+
if [ -z "$DELTA_FILES" ]; then REVIEW_MODE="FULL"; DELTA_FROM=""
|
|
419
|
+
else SELECTED_AGENTS=$(canonical_roster "$(delta_roster "$FULL_ROUNDS" "$SELECTED_AGENTS" "$DELTA_FILES")"); fi
|
|
420
|
+
fi
|
|
421
|
+
[ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ] && SELECTED_AGENTS=""
|
|
422
|
+
|
|
423
|
+
# --- templates, code index, per-domain slices (skipped for BASE_SYNC_ONLY: zero reviewers) ---
|
|
424
|
+
TEMPLATE_SOURCE="none"; TEMPLATE_BASE=""; MONOLITHIC_CATALOG=""
|
|
425
|
+
IDX_AUTH=""; IDX_BILLING=""; IDX_DATABASE=""; IDX_API=""; IDX_FRONTEND=""; IDX_INFRA=""
|
|
426
|
+
SLICES_JSON="{}"
|
|
427
|
+
if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
428
|
+
review_template_resolve "$REPO_PATH"
|
|
429
|
+
[ "$TEMPLATE_SOURCE" = "none" ] && block "no review-pr-agents template source resolved: run \`npx forgedock update\`; NEVER review inline"
|
|
430
|
+
if [ -n "$CODE_INDEX_SCRIPT" ]; then bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1 || true; fi
|
|
431
|
+
build_index_slice() {
|
|
432
|
+
local domain="${1}" files
|
|
433
|
+
if [ -n "$CODE_INDEX_SCRIPT" ]; then
|
|
434
|
+
files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $1}' | head -30 | tr '\n' ' ')
|
|
435
|
+
echo "Domain files (${domain}): ${files:-none}"
|
|
436
|
+
else echo "Code index not available — agent will use grep exploration"; fi
|
|
437
|
+
}
|
|
438
|
+
IDX_AUTH=$(build_index_slice auth); IDX_BILLING=$(build_index_slice billing); IDX_DATABASE=$(build_index_slice database)
|
|
439
|
+
IDX_API=$(build_index_slice api); IDX_FRONTEND=$(build_index_slice frontend); IDX_INFRA=$(build_index_slice infrastructure)
|
|
440
|
+
|
|
441
|
+
FULL_DIFF="$DIFF"
|
|
442
|
+
if [ "$REVIEW_MODE" = "DELTA" ]; then
|
|
443
|
+
DELTA_DIFF=$(git -C "${TOP:-.}" -c core.quotepath=off diff --no-color --no-ext-diff "${DELTA_FROM}..${REVIEW_SHA}" 2>/dev/null) || DELTA_DIFF=""
|
|
444
|
+
[ -n "$DELTA_DIFF" ] && FULL_DIFF="$DELTA_DIFF"
|
|
445
|
+
fi
|
|
446
|
+
SLICE_FILES_SRC="$FILES"; [ "$REVIEW_MODE" = "DELTA" ] && SLICE_FILES_SRC="$DELTA_FILES"
|
|
447
|
+
# slice <domain> <diff-header regex|ALL> <file-path regex|ALL>
|
|
448
|
+
slice() {
|
|
449
|
+
local dom="$1" dre="$2" fre="$3" body paths df="$OUT_DIR/slice-$1.diff"
|
|
450
|
+
if [ "$dre" = "ALL" ]; then body="$FULL_DIFF"
|
|
451
|
+
else
|
|
452
|
+
body=$(printf '%s\n' "$FULL_DIFF" | awk -v re="$dre" '/^diff --git/ { in_block=0 } $0 ~ re { in_block=1 } in_block { print }')
|
|
453
|
+
[ -n "$body" ] || body="$FULL_DIFF"
|
|
454
|
+
fi
|
|
455
|
+
if [ "$fre" = "ALL" ]; then paths="$SLICE_FILES_SRC"
|
|
456
|
+
else paths=$(printf '%s\n' "$SLICE_FILES_SRC" | grep -iE "$fre" || echo ""); [ -n "$paths" ] || paths="$SLICE_FILES_SRC"; fi
|
|
457
|
+
# 100K-char cap, same as the runner's tool-result cap
|
|
458
|
+
if [ "${#body}" -gt 102400 ]; then body="$(printf '%s' "$body" | head -c 102400)"$'\n'"... [TRUNCATED — diff exceeded 100K chars; focus on the files listed]"; fi
|
|
459
|
+
printf '%s\n' "$body" > "$df"
|
|
460
|
+
SLICES_JSON=$(printf '%s' "$SLICES_JSON" | jq -c --arg d "$dom" --arg df "$df" --arg p "$paths" '. + {($d): {paths: ($p | split("\n") | map(select(length > 0))), diff_file: $df}}')
|
|
461
|
+
}
|
|
462
|
+
slice security ALL ALL
|
|
463
|
+
slice auth '^diff --git.*/(auth|session|jwt|oauth|permission|middleware|login|token)' "auth|session|login|logout|jwt|oauth|token|user|permission|middleware"
|
|
464
|
+
slice billing '^diff --git.*/(billing|payment|credit|pricing|stripe|subscription|invoice|charge|refund)' "billing|credit|payment|charge|refund|subscription|pricing|tier"
|
|
465
|
+
slice concurrency '^diff --git.*/(worker|queue|task|async|lock|transaction|pipeline|job)' "lock|queue|atomic|transaction|worker|job|task|celery|redis|claim|lease|engine-state"
|
|
466
|
+
slice database '^diff --git.*/(migration|model|schema|\.sql$|orm|database|db)' "migration|\.sql$|model|schema|db|database|alembic"
|
|
467
|
+
slice frontend '^diff --git.*(web/src|components|pages|styles|\.tsx|\.jsx|\.css|\.scss)' "^web/|\.tsx?$|\.jsx?$|component|page|layout|style|css"
|
|
468
|
+
slice api '^diff --git.*/(router|route|endpoint|openapi|serializer|schema|api)' "router|route|endpoint|openapi|sdk|api"
|
|
469
|
+
slice infra '^diff --git.*/(docker|nginx|infra|\.github|deploy|ci|traefik|k8s|helm)' "docker|nginx|traefik|\.github|Makefile|deploy|infra"
|
|
470
|
+
slice scraper '^diff --git.*/(scrape|scraper|stealth|browser_pool|headless|anti_bot|captcha|crawl)' "scrape|crawler|stealth|browser.*pool|headless|anti.bot|detection.*keyword|captcha"
|
|
471
|
+
slice spec-cli '^diff --git a/(commands|bin)/' "^(commands|bin)/"
|
|
472
|
+
fi
|
|
473
|
+
|
|
474
|
+
# --- emit ---
|
|
475
|
+
STATUS="OK"; [ -n "$BLOCKERS" ] && STATUS="BLOCKED"
|
|
476
|
+
j_lines() { printf '%s' "${1}" | jq -R -s -c 'split("\n") | map(select(length > 0))'; }
|
|
477
|
+
j_words() { printf '%s' "${1}" | jq -R -s -c 'split(" ") | map(select(length > 0))'; }
|
|
478
|
+
CTX=$(jq -n \
|
|
479
|
+
--arg status "$STATUS" --argjson blockers "$(j_lines "$BLOCKERS")" \
|
|
480
|
+
--argjson pr_number "$PR_NUMBER" --arg repo "$REPO" --arg title "$TITLE" --arg author "$PR_AUTHOR" --arg head "$HEAD" --arg base "$BASE" \
|
|
481
|
+
--arg sha "$REVIEW_SHA" --arg sha7 "$REVIEW_SHA_SHORT" --argjson adds "$ADDITIONS" --argjson dels "$DELETIONS" --arg nfiles "${NFILES:-}" --arg first "$IS_FIRST_TIME_CONTRIBUTOR" \
|
|
482
|
+
--arg s2m "$IS_STAGING_TO_MAIN" --arg m2s "$IS_MILESTONE_TO_STAGING" --arg rfb "$REQUIRES_FULL_BUILD" --arg thorough "$THOROUGH" \
|
|
483
|
+
--arg mh "$MERGE_HEALTH" --arg mhs "$MERGE_HEALTH_STATE" --argjson mhr "$MERGE_RETRY" \
|
|
484
|
+
--argjson services "$(j_words "$SERVICES")" --argjson domains "$(j_words "$DOMAINS")" \
|
|
485
|
+
--arg tsb "$TS_BUILD" --arg pyf "$PY_FORMAT" --arg bcs "$BUILD_CONFIG_STATUS" --argjson p2 "$(j_lines "$P2_FINDINGS")" \
|
|
486
|
+
--argjson asm "$(j_lines "$ASSUMPTIONS")" --argjson risk "$(j_words "$RISK")" --arg churn "$CHURN_CONTEXT" --arg churn_esc "$CHURN_ESCALATION" --arg danger "$DANGER" \
|
|
487
|
+
--argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
|
|
488
|
+
--argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
|
|
489
|
+
--arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
|
|
490
|
+
--arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
|
|
491
|
+
--arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --argjson dfiles "$(j_lines "$DELTA_FILES")" \
|
|
492
|
+
--arg pname "$PROJECT_NAME" --arg pctx "$PROJECT_CONTEXT" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
|
|
493
|
+
--arg d_auth "$DOMAIN_CONTEXT_AUTH" --arg d_billing "$DOMAIN_CONTEXT_BILLING" --arg d_conc "$DOMAIN_CONTEXT_CONCURRENCY" --arg d_infra "$DOMAIN_CONTEXT_INFRA" \
|
|
494
|
+
--arg d_db "$DOMAIN_CONTEXT_DATABASE" --arg d_fe "$DOMAIN_CONTEXT_FRONTEND" --arg d_sec "$DOMAIN_CONTEXT_SECURITY" --arg d_api "$DOMAIN_CONTEXT_API" --arg d_scr "$DOMAIN_CONTEXT_SCRAPING" \
|
|
495
|
+
--arg tsrc "$TEMPLATE_SOURCE" --arg tbase "$TEMPLATE_BASE" --arg tmono "$MONOLITHIC_CATALOG" \
|
|
496
|
+
--arg i_auth "$IDX_AUTH" --arg i_billing "$IDX_BILLING" --arg i_db "$IDX_DATABASE" --arg i_api "$IDX_API" --arg i_fe "$IDX_FRONTEND" --arg i_infra "$IDX_INFRA" \
|
|
497
|
+
--argjson slices "$SLICES_JSON" --argjson full_files "$(j_lines "$FILES")" '
|
|
498
|
+
{schema_version: 1, status: $status, blockers: $blockers,
|
|
499
|
+
pr: {number: $pr_number, repo: $repo, title: $title, author: $author, head_ref: $head, base_ref: $base, review_sha: $sha, review_sha_short: $sha7,
|
|
500
|
+
additions: $adds, deletions: $dels, changed_files: ($nfiles | tonumber? // null), first_time_contributor: ($first == "true")},
|
|
501
|
+
lane: {is_staging_to_main: ($s2m == "true"), is_milestone_to_staging: ($m2s == "true"), requires_full_build: ($rfb == "true"), thorough: ($thorough == "true")},
|
|
502
|
+
merge_health: {mergeable: $mh, state: $mhs, retries: $mhr},
|
|
503
|
+
classification: {services: $services, domains: $domains},
|
|
504
|
+
phase2: {build_config: {typescript_build: $tsb, python_format: $pyf, status: $bcs}, findings: $p2},
|
|
505
|
+
broken_assumptions: $asm, risk_signals: $risk, churn_context: $churn, churn_escalation: ($churn_esc == "true"), danger_zone: ($danger == "true"),
|
|
506
|
+
scores: {AUTH: $sa, BILLING: $sb, CONCURRENCY: $sc, DATABASE: $sd, INFRA: $si, SECURITY: $ss, SCRAPING: $ssc, FRONTEND: $sf, API: $sap},
|
|
507
|
+
contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
|
|
508
|
+
roster: {tier: $rtier, reason: $rreason, agents: $agents},
|
|
509
|
+
delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles},
|
|
510
|
+
config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
|
|
511
|
+
domains: {auth: $d_auth, billing: $d_billing, concurrency: $d_conc, infra: $d_infra, database: $d_db, frontend: $d_fe, security: $d_sec, api: $d_api, scraping: $d_scr}},
|
|
512
|
+
templates: {source: $tsrc, base: $tbase, monolithic_catalog: $tmono},
|
|
513
|
+
index_slices: {auth: $i_auth, billing: $i_billing, database: $i_db, api: $i_api, frontend: $i_fe, infrastructure: $i_infra},
|
|
514
|
+
slices: $slices, full_files: $full_files}') || { echo "review-prepare: could not assemble the context JSON" >&2; exit 2; }
|
|
515
|
+
if [ -n "$OUT" ]; then printf '%s\n' "$CTX" > "$OUT" || { echo "review-prepare: cannot write $OUT" >&2; exit 2; }; else printf '%s\n' "$CTX"; fi
|
|
516
|
+
[ "$STATUS" = "OK" ] && exit 0
|
|
517
|
+
exit 3
|