diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b88718ae..801ddfe7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,7 @@ jobs: - run: python3 scripts/retain-macos-test-binaries.test.py - run: python3 scripts/bank_statement_import.test.py - run: python3 scripts/sanitise-bbox-capture.test.py + - run: python3 scripts/merge-gate.test.py tally-portable: name: Tally portable core diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 10986db86..d3374f017 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "74ac4427cf03860e7d4a7005682cccaa3d2544366e87f50fbe53ee79584a076b", + "compatibility_surface_sha256": "bef1632064166f9b80ff3beef9d0196b92667e4670d0834cdd183651a19a062a", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index ecad92293..c25468c4a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -3,7 +3,7 @@ "files": [ { "path": ".github/workflows/ci.yml", - "sha256": "2d2220d0dc1942eab04034dbfd07f5ecf693cc7d5f47b1e31615fe17e4b2f140" + "sha256": "f1d96290429f3d7d9821440ad989fc68b41d8ee3c99106d0c93151c95b03b6d1" }, { "path": ".github/workflows/dependency-security.yml", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "74ac4427cf03860e7d4a7005682cccaa3d2544366e87f50fbe53ee79584a076b" + "manifest_sha256": "bef1632064166f9b80ff3beef9d0196b92667e4670d0834cdd183651a19a062a" } \ No newline at end of file diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh new file mode 100755 index 000000000..4283d904f --- /dev/null +++ b/scripts/merge-gate.sh @@ -0,0 +1,1303 @@ +#!/usr/bin/env bash +# Decide whether a pull request may be merged, and say why not when it may not. +# +# Usage: scripts/merge-gate.sh [--repo OWNER/NAME] +# [--independent-review-sha FULL_SHA] (explicit manual review attestation) +# DSC/credential review record format (review or PR comment by another reviewer): +# Security review: FULL_SHA +# Result: accepted +# Include the reviewed scope and reasoning in that record. +# Exit: 0 may merge, 1 must not, 2 could not determine. +# +# Every positive result is bound to one server-observed head, base tip, complete +# check set, provider review commit, review-thread set, changed-file set, and +# readable compatibility surface. Unknown or incomplete evidence is never +# converted into an empty successful set. + +set -uo pipefail + +PR="" +REPO="" +INDEPENDENT_REVIEW_SHA="" +while [ $# -gt 0 ]; do + case "$1" in + --repo) + REPO="${2:-}" + [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; } + shift 2 + ;; + --repo=*) + REPO="${1#--repo=}" + [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; } + shift + ;; + --independent-review-sha) + INDEPENDENT_REVIEW_SHA="${2:-}" + [ -n "$INDEPENDENT_REVIEW_SHA" ] || { echo "--independent-review-sha needs a full commit SHA" >&2; exit 2; } + shift 2 + ;; + --independent-review-sha=*) + INDEPENDENT_REVIEW_SHA="${1#*=}" + [ -n "$INDEPENDENT_REVIEW_SHA" ] || { echo "--independent-review-sha= needs a full commit SHA" >&2; exit 2; } + shift + ;; + -h|--help) + sed -n '2,17p' "$0" + exit 0 + ;; + -*) + echo "unknown option: $1" >&2 + exit 2 + ;; + *) + if [ -z "$PR" ]; then + PR="$1" + else + echo "unexpected argument: $1" >&2 + exit 2 + fi + shift + ;; + esac +done +[ -n "$PR" ] || { echo "usage: $0 [--repo OWNER/NAME]" >&2; exit 2; } +[[ "$PR" =~ ^[0-9]+$ ]] || { echo "PR selector must be numeric" >&2; exit 2; } + +# Resolve the repository once. An explicit target must never fall back to the +# current checkout if one later API call fails. +if [ -z "$REPO" ]; then + if ! REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then + echo "could not determine repository; pass --repo OWNER/NAME" >&2 + exit 2 + fi +fi +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +if [ -z "$OWNER" ] || [ -z "$NAME" ] || [ "$OWNER" = "$REPO" ] || [[ "$REPO" == */*/* ]] \ + || ! [[ "$OWNER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ + || ! [[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "--repo must be OWNER/NAME, got '$REPO'" >&2 + exit 2 +fi + +# This command encodes Bridge-specific master workflow and surface policy. +# An arbitrary repository's passing checks cannot qualify that contract. +if [ "$REPO" != "lamemustafa/bridge" ]; then + echo "unsupported repository: this gate implements lamemustafa/bridge policy" >&2 + exit 2 +fi + +fail=0 +uncertain=0 +tmpdir=$(mktemp -d) +errfile="$tmpdir/error" +trap 'rm -rf "$tmpdir"' EXIT +say() { printf ' %-13s %s\n' "$1" "$2"; } +bad() { say "BLOCK" "$1"; fail=1; } +unknown() { say "INDETERMINATE" "$1"; uncertain=1; } +die() { echo "$1" >&2; exit 2; } + +# A malformed response is different from a valid empty result. Validate the +# outer shape before extracting fields so jq errors cannot become empty values. +# Independent acceptance decides whether a change is a production regression, +# whether its branch is dedicated, and whether it carries the required type:rectify label. +: >"$errfile" +if ! meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,title,body,changedFiles 2>"$errfile"); then + die "could not read PR #$PR in $REPO" +fi +if ! jq -e ' + type == "object" and + (.headRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.baseRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.baseRefName | type == "string" and length > 0) and + (.mergeable | type == "string") and + (.mergeStateStatus | type == "string") and + (.isDraft | type == "boolean") and + (.state | type == "string") and + (.title | type == "string") and + (.changedFiles | type == "number" and floor == . and . >= 0) +' <<<"$meta" >/dev/null 2>&1; then + die "PR metadata was not a valid complete JSON object" +fi +head=$(jq -r '.headRefOid' <<<"$meta") +base_ref_oid=$(jq -r '.baseRefOid' <<<"$meta") +base=$(jq -r '.baseRefName' <<<"$meta") +mergeable=$(jq -r '.mergeable' <<<"$meta") +mstate=$(jq -r '.mergeStateStatus' <<<"$meta") +draft=$(jq -r '.isDraft' <<<"$meta") +pstate=$(jq -r '.state' <<<"$meta") +changed_files_expected=$(jq -r '.changedFiles' <<<"$meta") +short=${head:0:7} +title=$(jq -r '.title' <<<"$meta") +raw_prbody=$(jq -r '.body // ""' <<<"$meta") +prbody="$raw_prbody" +visible_body_status=0 +prbody=$(python3 -c 'import re, sys +text = sys.stdin.read() +print(re.sub(r"|\Z)", "", text, flags=re.S), end="")' <<<"$prbody") || visible_body_status=$? +if [ "$visible_body_status" -ne 0 ]; then + unknown "could not extract visible PR description content" + prbody="" +fi + +if [ -n "$INDEPENDENT_REVIEW_SHA" ] && ! [[ "$INDEPENDENT_REVIEW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + bad "independent review attestation must be a full 40-hex commit SHA" +elif [ -n "$INDEPENDENT_REVIEW_SHA" ] && [ "$INDEPENDENT_REVIEW_SHA" != "$head" ]; then + bad "independent review attestation names a different commit than the PR head" +fi + +echo "PR #$PR ($REPO) head=$short base=$base $mergeable/$mstate" +[ "$pstate" = "OPEN" ] || bad "PR is $pstate, not OPEN" +[ "$draft" = "false" ] || bad "draft" + +case "$mergeable" in + MERGEABLE) say "ok" "no conflicts" ;; + CONFLICTING) bad "conflicts with $base — rebase first" ;; + *) unknown "mergeability is '$mergeable'; re-run when GitHub has determined it" ;; +esac +case "$mstate" in + BEHIND) bad "head is BEHIND $base — its checks and review describe a stale tree; rebase" ;; + DIRTY) bad "merge state DIRTY — conflicts" ;; + UNKNOWN) unknown "merge state UNKNOWN; re-run in a moment" ;; + BLOCKED) bad "merge state BLOCKED — GitHub is refusing this merge" ;; + CLEAN|HAS_HOOKS) say "ok" "merge state $mstate is not stale" ;; + UNSTABLE) bad "merge state UNSTABLE — GitHub has not established a mergeable result" ;; + DRAFT) bad "merge state DRAFT — the PR is not ready for merge" ;; + *) unknown "unrecognised merge state '$mstate'" ;; +esac + +if [ "$base" = "master" ]; then + say "ok" "based on master (CI actually runs)" +else + bad "based on '$base', not master — ci.yml does not fire, so checks here prove nothing" +fi + +# Capture the base tip independently. A PR can retain the same base name while +# the branch advances during this run. +: >"$errfile" +base_tip_status=0 +base_tip=$(gh api "repos/$REPO/branches/$base" --jq '.commit.sha' 2>"$errfile") || base_tip_status=$? +if [ "$base_tip_status" -ne 0 ] || ! [[ "$base_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + unknown "could not read the full current tip of base '$base'" + base_tip="" +else + say "ok" "captured base tip ${base_tip:0:7}" +fi +if [ -n "$base_tip" ] && [ "$base_ref_oid" != "$base_tip" ]; then + unknown "PR base OID $base_ref_oid differs from the current '$base' tip $base_tip" +fi + +# Bind the reviewed head to the actual base lineage returned by GitHub. The +# compare API is queried with the captured OIDs and must say that base is an +# ancestor of head; mergeability alone does not establish that relationship. +if [ -n "$base_tip" ] && [ "$base_ref_oid" = "$base_tip" ]; then + : >"$errfile" + compare_status=0 + comparison=$(gh api "repos/$REPO/compare/${base_tip}...${head}" 2>"$errfile") || compare_status=$? + if [ "$compare_status" -ne 0 ] || ! jq -e --arg base "$base_tip" ' + type == "object" and + (.status | type == "string" and (. == "ahead" or . == "identical")) and + (.behind_by | type == "number" and floor == . and . == 0) and + (.merge_base_commit | type == "object") and + (.merge_base_commit.sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $base) + ' <<<"$comparison" >/dev/null 2>&1; then + unknown "base/head compare did not prove that the captured base tip is an ancestor" + else + say "ok" "compare API binds base tip ${base_tip:0:7} as head's merge base" + fi +fi + +# Branch protection is the source of required check contexts. A pass list with +# an omitted required context is not a complete check result. +: >"$errfile" +protection_status=0 +required_contexts="" +protection=$(gh api "repos/$REPO/branches/$base/protection/required_status_checks" 2>"$errfile") || protection_status=$? +if [ "$protection_status" -ne 0 ]; then + unknown "could not read required status-check contexts for $base" + required_contexts="" +elif ! jq -e ' + type == "object" and + (.strict | type == "boolean" and . == true) and + ((.contexts // []) | type == "array" and all(.[]; type == "string" and length > 0)) and + ((.checks // []) | type == "array" and all(.[]; type == "object" and (.context | type == "string" and length > 0))) +' <<<"$protection" >/dev/null 2>&1; then + unknown "required status-check response was malformed" + required_contexts="" +else + required_contexts=$(jq -r '((.contexts // []) + ([.checks // [] | .[]? | .context] | map(select(type == "string" and length > 0))) | unique)[]' <<<"$protection") + documented_contexts=$'Dependency security\nFrontend build\nGitGuardian Security Checks\nRequired checks\nRust format' + if [ -z "$required_contexts" ]; then + unknown "branch protection returned no required status-check contexts" + elif missing_documented=$(comm -23 <(sort -u <<<"$documented_contexts") <(sort -u <<<"$required_contexts")) && [ -n "$missing_documented" ]; then + bad "branch protection omits $(wc -l <<<"$missing_documented" | tr -d ' ') documented required check context(s)" + elif unexpected_documented=$(comm -13 <(sort -u <<<"$documented_contexts") <(sort -u <<<"$required_contexts")) && [ -n "$unexpected_documented" ]; then + bad "branch protection includes $(wc -l <<<"$unexpected_documented" | tr -d ' ') undocumented required check context(s)" + else + say "ok" "loaded $(wc -l <<<"$required_contexts" | tr -d ' ') required check context(s)" + fi +fi +printf '%s\n' "$required_contexts" >"$tmpdir/required-contexts" + +# gh uses pass/fail/pending/skipping/cancel buckets. Preserve command status, +# then parse JSON and require each protected context individually. +: >"$errfile" +check_status=0 +buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>"$errfile") || check_status=$? +if [ -z "$buckets" ]; then + unknown "checks query returned no JSON" +elif ! jq -e 'type == "array" and all(.[]; type == "object" and (.name | type == "string" and length > 0) and (.bucket | type == "string"))' <<<"$buckets" >/dev/null 2>&1; then + unknown "checks query returned malformed JSON" +else + # The first validation accepts only the documented buckets; keep this second + # check explicit because jq's precedence is easy to misread in a gate. + if ! jq -e 'all(.[]; (.bucket == "pass" or .bucket == "fail" or .bucket == "pending" or .bucket == "skipping" or .bucket == "cancel"))' <<<"$buckets" >/dev/null 2>&1; then + unknown "checks query contained an unknown bucket" + elif [ "$check_status" -ne 0 ] && [ "$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending")] | length' <<<"$buckets")" -eq 0 ]; then + unknown "checks command failed even though no failing or pending result was returned" + elif [ "$(jq 'length' <<<"$buckets")" -eq 0 ]; then + bad "no checks reported for this PR" + else + printf '%s' "$buckets" >"$tmpdir/check-buckets.json" + context_report_status=0 + context_report_valid=false + context_report=$(jq --rawfile contexts "$tmpdir/required-contexts" ' + . as $buckets | ($contexts | split("\n") | map(select(length > 0))) | + map(. as $context | [$buckets[] | select(.name == $context)] | + {name: $context, state: (if length == 0 then "missing" + elif all(.[]; .bucket == "pass") then "pass" + else map(.bucket) | unique | join(",") end)}) | + {total: length, failed: ([.[] | select(.state != "pass")] | length), + examples: ([.[] | select(.state != "pass")][0:8] | map( + "required check \u0027" + (.name | gsub("[[:cntrl:]]"; "?") | .[0:80]) + "\u0027 " + + (if .state == "missing" then "was not reported" else "is not passing (" + .state + ")" end)))} + ' <"$tmpdir/check-buckets.json") || context_report_status=$? + if [ "$context_report_status" -ne 0 ] || ! jq -e '. as $report | type == "object" and ($report.total | type == "number" and floor == . and . >= 0) and ($report.failed | type == "number" and floor == . and . >= 0 and . <= $report.total) and ($report.examples | type == "array" and length <= 8 and all(.[]; type == "string"))' <<<"$context_report" >/dev/null 2>&1; then + unknown "could not compute bounded required-check diagnostics" + else + context_report_valid=true + check_bad=$(jq -r '.failed' <<<"$context_report") + context_count=$(jq -r '.total' <<<"$context_report") + if [ "$check_bad" -gt 0 ]; then + bad "$check_bad of $context_count required check contexts are not passing; up to 8 bounded examples: $(jq -r '.examples | join("; ")' <<<"$context_report")" + else + say "ok" "all $context_count required check contexts passed" + fi + fi + all_bad=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending")] | length' <<<"$buckets") + skipped=$(jq '[.[] | select(.bucket == "skipping")] | length' <<<"$buckets") + [ "$all_bad" -eq 0 ] || bad "$all_bad reported check(s) are failing, cancelled, or pending" + [ "$skipped" -eq 0 ] || say "note" "$skipped optional check(s) are skipped; required skipped contexts remain blocking" + [ "$context_report_valid" = true ] && [ "$check_bad" -eq 0 ] && [ "$all_bad" -eq 0 ] && say "ok" "all reported checks concluded successfully" + fi +fi + +# The checks rollup is head-bound by its PR endpoint, but it does not expose a +# check-run SHA in `gh pr checks`. Verify the complete provider check-run pages +# and commit-status response independently so a malformed or mixed response +# cannot become positive evidence. The required-context decision above remains +# authoritative for branch protection, including status-only contexts. +: >"$errfile" +check_runs_status=0 +check_runs=$(gh api --paginate --slurp "repos/$REPO/commits/$head/check-runs?per_page=100" 2>"$errfile") || check_runs_status=$? +if [ "$check_runs_status" -ne 0 ] || ! jq -e --arg head "$head" ' + type == "array" and length > 0 and + all(.[]; type == "object" and + (.total_count | type == "number" and floor == . and . >= 0) and + (.check_runs | type == "array" and all(.[]; + type == "object" and + (.id | type == "number" and floor == . and . >= 0) and + (.name | type == "string" and length > 0) and + (.status == "completed") and + (.conclusion | type == "string" and (. == "success" or . == "skipped" or . == "neutral" or . == "failure" or . == "cancelled" or . == "timed_out" or . == "action_required" or . == "stale")) and + (.head_sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $head) + ))) and + ((map(.total_count) | unique | length) == 1) and + ((map(.check_runs | length) | add) == .[0].total_count) and + ((map(.check_runs) | add | map(.id) | unique | length) == .[0].total_count) +' <<<"$check_runs" >/dev/null 2>&1; then + unknown "could not validate complete head-bound check-run evidence" +else + failed_runs_status=0 + failed_runs=$(jq --rawfile contexts "$tmpdir/required-contexts" ' + ($contexts | split("\n")) as $required | + [.[] | .check_runs[] | . as $run | + select((.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped") or + (.conclusion != "success" and ($required | index($run.name)) != null))] | length + ' <(printf '%s' "$check_runs")) || failed_runs_status=$? + if [ "$failed_runs_status" -ne 0 ] || ! [[ "$failed_runs" =~ ^[0-9]+$ ]]; then + unknown "could not evaluate refreshed check-run conclusions" + elif [ "$failed_runs" -gt 0 ]; then + bad "$failed_runs refreshed check run(s) are failed or required-but-not-successful" + else + say "ok" "completed check-run pages are successful and bound to head $short" + fi +fi +: >"$errfile" +statuses_status=0 +statuses=$(gh api --paginate --slurp "repos/$REPO/commits/$head/status?per_page=100" 2>"$errfile") || statuses_status=$? +printf '%s' "$statuses" >"$tmpdir/combined-status-pages.json" +if [ "$statuses_status" -ne 0 ] || ! jq -e --arg head "$head" ' + type == "array" and length > 0 and + all(.[]; type == "object" and + (.sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $head) and + (.total_count | type == "number" and floor == . and . >= 0) and + (.state | type == "string" and (. == "success" or . == "pending")) and + (.statuses | type == "array" and all(.[]; + type == "object" and + (.id | type == "number" and floor == . and . >= 0) and + (.context | type == "string" and length > 0) and + (.state == "success") + )) + ) and + ((map(.total_count) | unique | length) == 1) and + ((map(.statuses | length) | add) == .[0].total_count) and + ((map(.statuses) | add | map(.id) | unique | length) == .[0].total_count) and + ((map(.statuses) | add | map(.context) | unique | length) == .[0].total_count) and + (if .[0].state == "pending" then .[0].total_count == 0 and (map(.statuses | length) | add) == 0 else all(.[]; .state == "success") end) +' <"$tmpdir/combined-status-pages.json" >/dev/null 2>&1; then + unknown "could not validate complete head-bound commit-status evidence" +else + say "ok" "complete commit-status pages are bound to head $short" +fi + +# Provider review objects carry an immutable full commit_id even when the +# human-readable summary is abbreviated. The summary is required as a separate +# completed-run receipt: an exact-head COMMENTED object can remain after a run +# later fails. A clean summary without a full provider OID has an explicit +# operator-attestation path, but a seven-character row never becomes a full-SHA +# claim by inference. +: >"$errfile" +review_status=0 +provider_review="" +reviews=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/reviews" 2>"$errfile") || review_status=$? +if [ "$review_status" -ne 0 ]; then + unknown "could not read provider review records" +elif ! jq -e 'type == "array" and (all(.[]; type == "array") or all(.[]; type == "object"))' <<<"$reviews" >/dev/null 2>&1; then + unknown "provider review response was malformed" +else + provider_review=$(jq -r --arg head "$head" ' + (if all(.[]; type == "array") then flatten else . end) | + map(select(.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot" and + .state == "COMMENTED" and .commit_id == $head)) | + if length > 0 then "matched" else "" end + ' <<<"$reviews") +fi + +: >"$errfile" +comment_status=0 +comments=$(gh api --paginate --slurp "repos/$REPO/issues/$PR/comments" 2>"$errfile") || comment_status=$? +summary_good=0 +if [ "$comment_status" -ne 0 ]; then + unknown "could not read provider review summaries" +elif ! jq -e 'type == "array" and (all(.[]; type == "array") or all(.[]; type == "object"))' <<<"$comments" >/dev/null 2>&1; then + unknown "provider review-summary response was malformed" +else + summary_rows=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end)[] | + select(.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot") | + select((.body // "") | contains("codex-pull-request-review-summary")) | + .body + ' <<<"$comments" | grep -E '^\| (📝|🔍)' | tail -1) + if [ -z "$summary_rows" ]; then + bad "no provider review summary row" + elif ! grep -Fq "\`$short\`" <<<"$summary_rows"; then + bad "latest provider summary names a different head than $short" + elif ! grep -Fq 'Completed' <<<"$summary_rows"; then + bad "provider review run for $short is not completed" + else + summary_good=1 + say "ok" "provider review summary is completed on $short" + fi +fi + +if [ "$provider_review" = "matched" ] && [ "$summary_good" -eq 1 ]; then + say "ok" "provider review records the full current head $short" +elif [ "$provider_review" != "matched" ] && [ "$summary_good" -eq 1 ]; then + if [ -n "$INDEPENDENT_REVIEW_SHA" ] && [ "$INDEPENDENT_REVIEW_SHA" = "$head" ]; then + say "ok" "manual independent review attestation names full current head $short" + elif [ -n "$INDEPENDENT_REVIEW_SHA" ]; then + bad "manual independent review attestation does not match the current head" + else + unknown "provider summary exposes only an abbreviated head; pass --independent-review-sha with an exact manual review attestation" + fi +fi + +# Scan all published PR metadata. Commit messages are paginated because they +# can become squash subjects or release evidence independently of the patch. +: >"$errfile" +metadata_pr_status=0 +metadata_pr=$(gh api "repos/$REPO/pulls/$PR" 2>"$errfile") || metadata_pr_status=$? +if [ "$metadata_pr_status" -ne 0 ] || ! jq -e --arg head "$head" ' + type == "object" and + (.commits | type == "number" and floor == . and . > 0 and . <= 250) and + (.head | type == "object" and + (.sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $head)) +' <<<"$metadata_pr" >/dev/null 2>&1; then + unknown "could not prove complete head-bound PR commit metadata for the privacy scan" + privacy_metadata="" +else + metadata_commit_total=$(jq -r '.commits' <<<"$metadata_pr") +fi + +: >"$errfile" +metadata_status=0 +metadata_commits=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/commits?per_page=100" 2>"$errfile") || metadata_status=$? +if [ -z "${metadata_commit_total:-}" ] || [ "$metadata_status" -ne 0 ] || ! jq -e --argjson expected "$metadata_commit_total" --arg head "$head" ' + type == "array" and (all(.[]; type == "array") or all(.[]; type == "object")) and + ((if all(.[]; type == "array") then flatten else . end) | + length == $expected and + ([.[].sha] | unique | length) == $expected and + any(.[]; .sha == $head) and + all(.[]; type == "object" and + (.sha | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.commit | type == "object") and + (.commit.message | type == "string") and + (.commit.author | type == "object" and + (.name | type == "string") and (.email | type == "string")) and + (.commit.committer | type == "object" and + (.name | type == "string") and (.email | type == "string")) and + ((.author == null) or (.author | type == "object" and (.login | type == "string"))) and + ((.committer == null) or (.committer | type == "object" and (.login | type == "string"))))) +' <<<"$metadata_commits" >/dev/null 2>&1; then + unknown "could not prove complete head-bound PR commit metadata for the privacy scan" + privacy_metadata="" +else + # These are Git's standard author/committer and linked-account fields. The + # privacy scanner checks identifier/path shapes in their literal values; it + # does not claim that ordinary names or email addresses are private data. + commit_messages=$(jq -r '(if all(.[]; type == "array") then flatten else . end)[] | + [.commit.message, .commit.author.name, .commit.author.email, + .commit.committer.name, .commit.committer.email, + (.author.login? // null), (.committer.login? // null)] | + map(select(. != null))[]' <<<"$metadata_commits") + privacy_metadata="$title +$raw_prbody +$commit_messages" +fi + +# Paginate review threads and count unresolved nodes over every page. +cursor="" +open_threads=0 +total_threads=-1 +fetched_threads=0 +thread_ids="$tmpdir/review-thread-ids" +: >"$thread_ids" +thread_ok=1 +while :; do + : >"$errfile" + page_status=0 + if [ -z "$cursor" ]; then + page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f cursor="" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ reviewThreads(first:100,after:$cursor){ + totalCount pageInfo{hasNextPage endCursor} nodes{id isResolved} + }} + } + }' 2>"$errfile") || page_status=$? + else + page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f cursor="$cursor" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ reviewThreads(first:100,after:$cursor){ + totalCount pageInfo{hasNextPage endCursor} nodes{id isResolved} + }} + } + }' 2>"$errfile") || page_status=$? + fi + if [ "$page_status" -ne 0 ] || ! jq -e '.data.repository.pullRequest.reviewThreads | type == "object" and (.totalCount | type == "number" and floor == . and . >= 0) and (.pageInfo.hasNextPage | type == "boolean") and (.nodes | type == "array" and all(.[]; (.id | type == "string" and length > 0) and (.isResolved | type == "boolean")))' <<<"$page" >/dev/null 2>&1; then + unknown "could not read review threads for $REPO#$PR" + thread_ok=0 + break + fi + page_total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page") + if [ "$total_threads" -eq -1 ]; then + total_threads="$page_total" + elif [ "$page_total" -ne "$total_threads" ]; then + unknown "review-thread totalCount changed during pagination" + thread_ok=0 + break + fi + page_nodes=$(jq '.data.repository.pullRequest.reviewThreads.nodes | length' <<<"$page") + jq -r '.data.repository.pullRequest.reviewThreads.nodes[].id' <<<"$page" >>"$thread_ids" + fetched_threads=$((fetched_threads + page_nodes)) + if [ "$fetched_threads" -gt "$total_threads" ]; then + unknown "review-thread pagination exceeded totalCount" + thread_ok=0 + break + fi + page_open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' <<<"$page") + open_threads=$((open_threads + page_open)) + has_next=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page") + [ "$has_next" = "true" ] || break + if [ "$page_nodes" -eq 0 ]; then + unknown "review-thread pagination returned no nodes while claiming another page" + thread_ok=0 + break + fi + next_cursor=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // empty' <<<"$page") + if [ -z "$next_cursor" ] || [ "$next_cursor" = "$cursor" ]; then + unknown "review-thread pagination returned no advancing cursor" + thread_ok=0 + break + fi + cursor="$next_cursor" +done +if [ "$thread_ok" -eq 1 ]; then + unique_thread_count=$(sort -u "$thread_ids" | wc -l | tr -d ' ') + if [ "$unique_thread_count" -ne "$fetched_threads" ]; then + unknown "review-thread pagination repeated thread IDs" + elif [ "$fetched_threads" -ne "$total_threads" ]; then + unknown "review-thread pagination returned $fetched_threads of $total_threads nodes" + elif [ "$open_threads" -eq 0 ]; then + say "ok" "0 of $total_threads review threads unresolved" + else + bad "$open_threads of $total_threads review threads unresolved" + fi +fi + +# Require a completed checklist item and a same-repository line permalink. +# The repository template puts the permalink on the item's indented +# continuation, so accept it there as well as in an inline Markdown link. +# A filename in prose, a foreign link, or an anchor outside the current +# checklist file is not completion evidence. +read_review_checklist() { + local response content decoded decode_status + : >"$errfile" + response=$(gh api "repos/$REPO/contents/review-checklist.md?ref=$head" 2>"$errfile") || return 1 + content=$(jq -er 'select(.encoding == "base64") | .content | strings' <<<"$response") || return 1 + decode_status=0 + decoded=$(printf '%s' "${content//$'\n'/}" | base64 --decode 2>"$errfile") || decode_status=$? + if [ "$decode_status" -ne 0 ]; then + decode_status=0 + decoded=$(printf '%s' "${content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? + fi + [ "$decode_status" -eq 0 ] && [ -n "$decoded" ] || return 1 + review_checklist="$decoded" +} +checklist_link_ok() { + local body="$1" checklist="$2" line awaiting_permalink=0 link anchor + local checked='^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]+' + local permalink="https://github\.com/${OWNER}/${NAME}/blob/${head}/review-checklist\.md#L[0-9]+" + local permalink_boundary="${permalink}"'([[:space:]]|\)|$)' + while IFS= read -r line; do + if printf '%s\n' "$line" | grep -Eq "$checked"; then + if printf '%s\n' "$line" | grep -Eiq "$permalink_boundary"; then + awaiting_permalink=2 + elif printf '%s\n' "$line" | grep -Eiq 'review-checklist\.md'; then + awaiting_permalink=1 + else + awaiting_permalink=0 + fi + elif [ "$awaiting_permalink" -eq 1 ] && printf '%s\n' "$line" | grep -Eq '^[[:space:]]+'; then + if printf '%s\n' "$line" | grep -Eiq "$permalink_boundary"; then + awaiting_permalink=2 + fi + elif [ "$awaiting_permalink" -ne 2 ]; then + awaiting_permalink=0 + fi + if [ "$awaiting_permalink" -eq 2 ]; then + while IFS= read -r link; do + anchor=${link##*#L} + anchor=${anchor%%[^0-9]*} + if sed -n "${anchor}p" <<<"$checklist" | grep -Eq '^[[:space:]]*-[[:space:]]*\[[ xX]\][[:space:]]+[^[:space:]]'; then + return 0 + fi + done < <(printf '%s\n' "$line" | grep -Eio "$permalink_boundary") + awaiting_permalink=0 + fi + done <<<"$body" + return 1 +} +review_checklist="" +checklist_status=0 +read_review_checklist || checklist_status=$? +if [ "$checklist_status" -ne 0 ]; then + unknown "could not read review-checklist content for link validation" +elif ! checklist_link_ok "$prbody" "$review_checklist"; then + bad "description lacks a completed same-repository review-checklist link to an existing line" +else + say "ok" "description links a completed review-checklist item" +fi + +body_section_has_content() { + local body="$1" labels="$2" allow_placeholders="${3:-false}" + awk -v labels="$labels" -v allow_placeholders="$allow_placeholders" ' + function heading(line, lower) { + lower = tolower(line) + sub(/^[[:space:]]*#+[[:space:]]*/, "", lower) + return lower ~ ("^(" labels ")[[:space:]]*:[[:space:]]*[^[:space:]]") || + lower ~ ("^(" labels ")[[:space:]]*:?[[:space:]]*$") + } + function template_prompt(line, lower) { + lower = tolower(line) + return lower == "what concrete user or maintainer workflow changes, and why now?" || + lower ~ /^-[[:space:]]*\[[xX]\][[:space:]]/ || + lower ~ /^-[[:space:]]*exact candidate sha:/ || + lower ~ /^-[[:space:]]*commands and results[[:space:]]*\(.*\):[[:space:]]*$/ || + lower ~ /^-[[:space:]]*captured\/fixture\/live scope and known limitations:[[:space:]]*$/ || + lower ~ /^-[[:space:]]*manual\/ui evidence[[:space:]]*\(.*\):[[:space:]]*$/ + } + function after_colon(line, value) { + value = line + sub(/^[^:]*:[[:space:]]*/, "", value) + return value + } + function meaningful(value, lower) { + gsub(//, "", value) + lower = tolower(value) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", lower) + if (lower == "") return 0 + if (allow_placeholders == "true" && lower ~ /^(n\/a|none|no impact)$/) return 1 + return lower !~ /^(n\/a|none|pending|todo|tbd|not applicable|unaffected|not affected|not impacted|no impact)$/ + } + { + # The canonical PR template uses labelled list fields as well as + # headings. Accept a filled field for the requested policy label, but + # never the untouched label or a checkbox by itself. + lower = tolower($0) + gsub(//, "", lower) + if (pending_list) { + if (lower ~ /^[[:space:]]*$/) { + pending_list = 0 + } else if (lower ~ /^[[:space:]]*[-#]/) { + pending_list = 0 + } else if (lower !~ /^[[:space:]]+/) { + pending_list = 0 + } else if (lower ~ /:[[:space:]]*[^[:space:]]/ && meaningful(after_colon(lower))) { + found = 1 + exit + } else if (lower ~ /:[[:space:]]*$/) { + pending_list = 2 + } else if (pending_list == 2 && lower ~ /[^[:space:]]/) { + if (!template_prompt($0) && lower !~ /^[[:space:]]*", "", sys.stdin.read(), flags=re.S) +active = fenced = False +for line in text.splitlines(): + heading = re.match(r"^\s*#{1,6}\s+(.+?)\s*$", line) + if heading: + active = bool(re.fullmatch(r"(?:test or reproduction command|commands and results|validation and evidence):?", heading[1], re.I)) + fenced = False + continue + if not active: + continue + if re.match(r"^\s*```", line): + fenced = not fenced + continue + candidates = [(line.strip()[2:] if line.strip().startswith("$ ") else line.strip())] if fenced else re.findall(r"`([^`]+)`", line) + for command in candidates: + if re.search(r"\.\.\.|…|<[^>]+>", command): + continue + try: + words = shlex.split(command) + except ValueError: + continue + if words and words[0] == "corepack": + words = words[1:] + if len(words) >= 2 and (words[0] in {"python", "python3", "pytest", "pnpm", "npm", "cargo", "make", "bash", "sh", "gh"} or words[0].startswith("scripts/")): + sys.exit(0) +sys.exit(1) +' <<<"$1" +} +if ! body_has_validation_command "$prbody"; then + bad "description lacks an actual test or reproduction command" +fi + +# P4 is a three-part design record, not a generic scope paragraph. When a +# patch adds implementation code, each question must have an answer that is +# more than the untouched template label. +body_has_p4_answers() { + local body="$1" + body_section_has_content "$body" 'existing component reused' && + body_section_has_content "$body" 'what is deleted' && + body_section_has_content "$body" 'what breaks if this is not built' +} + +body_has_platform_evidence() { + local body="$1" host="$2" + # A bare checked template box has no host, command, or rationale. Require + # a filled heading/list field that names the host and either evidence or a + # justified unaffected statement. + python3 -c ' +import re, sys +host = sys.argv[1].lower() +for line in sys.stdin.read().splitlines(): + lower = line.strip().lower() + lower = re.sub(r"", "", lower).strip() + if host not in lower: + continue + if re.search(r"(validation|evidence|test|check|unaffected|not applicable|not affected|not impact)", lower): + value = lower.split(":", 1)[1].strip() if ":" in lower else "" + placeholders = { + "none", "n/a", "not applicable", "unaffected", "not affected", + "not impacted", "no impact", "pending", "todo", "tbd", + } + if value and value not in placeholders: + raise SystemExit(0) +raise SystemExit(1) +' "$host" <<<"$body" +} + +# Paginate changed files through the REST endpoint; gh pr view hard-codes a +# first:100 GraphQL fragment in some versions. Retain the line counts as well: +# the privacy scan can only be complete when the textual diff describes every +# non-removed destination with the byte count GitHub reported. +: >"$errfile" +files_status=0 +files=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/files?per_page=100" 2>"$errfile") || files_status=$? +changed_records="$tmpdir/changed-files.tsv" +if [ "$files_status" -ne 0 ] || ! jq -e ' + type == "array" and + (all(.[]; type == "array" and all(.[]; + type == "object" and + ((.filename | type) == "string") and (.filename | length > 0) and (.filename | test("[\u0000-\u001F\u007F]") | not) and + ((.status | type) == "string") and (.status | length > 0) and + (((.previous_filename? == null) or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not))) and ((.status != "renamed") or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not)))) and + ((.additions | type) == "number") and (.additions | floor == . and . >= 0) and + ((.deletions | type) == "number") and (.deletions | floor == . and . >= 0))) or + all(.[]; type == "object" and + ((.filename | type) == "string") and (.filename | length > 0) and (.filename | test("[\u0000-\u001F\u007F]") | not) and + ((.status | type) == "string") and (.status | length > 0) and + (((.previous_filename? == null) or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not))) and ((.status != "renamed") or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not)))) and + ((.additions | type) == "number") and (.additions | floor == . and . >= 0) and + ((.deletions | type) == "number") and (.deletions | floor == . and . >= 0))) +' <<<"$files" >/dev/null 2>&1; then + unknown "could not read the complete changed-file set" + changed="" +else + jq -r '(if all(.[]; type == "array") then flatten else . end)[] | [.filename, .status, .additions, .deletions, (.previous_filename? // "")] | @tsv' <<<"$files" >"$changed_records" + changed=$(cut -f1 "$changed_records") + changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | length' <<<"$files") + unique_changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | map(.filename) | unique | length' <<<"$files") + if [ "$changed_count" -eq 0 ]; then + unknown "changed-file response contained no filenames" + elif [ "$changed_files_expected" -gt 3000 ]; then + unknown "PR reports $changed_files_expected changed files beyond the REST files API cap" + elif [ "$changed_count" -ne "$changed_files_expected" ] || [ "$unique_changed_count" -ne "$changed_count" ]; then + unknown "changed-file response has $changed_count unique records; PR metadata reports $changed_files_expected" + fi +fi + +# Existing workflow changes require the rollback and migration-compatibility +# notes mandated by the project review flow. The complete REST file set, rather +# than the rendered diff, is the authority for this conditional requirement. +workflow_change=0 +if [ -n "$files_status" ] && [ "$files_status" -eq 0 ]; then + workflow_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; (.filename | startswith(".github/workflows/")) or + ((.previous_filename? // "") | startswith(".github/workflows/"))) + ' <<<"$files") +fi +native_frontend_change=0 +if [ "$files_status" -eq 0 ]; then + native_frontend_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [ .filename, (.previous_filename? // "") ][] | + test("^(src-tauri/|src/|scripts/.*\\.(ts|tsx|js|mjs)$)")) + ' <<<"$files") +fi + +# Treat source additions as implementation work only when the REST line totals +# prove that bytes were added. Rename paths are considered for every path +# policy below, so moving platform, migration, or sensitive code cannot evade +# the relevant review record. +implementation_code_added=false +platform_sensitive_change=false +migration_change=false +if [ "$files_status" -eq 0 ]; then + implementation_code_added=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; (.additions > 0) and (.filename | test("\\.(rs|ts|tsx|js|mjs|py|go|java|kt|swift|c|cc|cpp|h|hpp|sh|bash|ps1|psm1|sql)$"))) + ' <<<"$files") + platform_sensitive_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [.filename, (.previous_filename? // "")][] | + test("^(src-tauri/|src/.*\\.(rs|ts|tsx|js|mjs)$)|(^|/)(windows|macos|darwin|win32|local_files|paths)(/|[._-])"; "i")) + ' <<<"$files") + migration_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [.filename, (.previous_filename? // "")][] | + test("(^|/)(migrations?|schema|database|db)(/|[._-])"; "i")) + ' <<<"$files") +fi + +# The review contract requires an explicit security-impact statement whenever +# either spelling of a renamed path touches a DSC, credential, or Tally surface. +# Treat native and frontend source conservatively: their generic filenames can +# carry DSC/Tally behavior. Use the complete REST inventory, including removals +# and prior rename paths; named tooling and documentation surfaces are included. +security_sensitive_change=0 +if [ "$files_status" -eq 0 ]; then + security_sensitive_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [ .filename, (.previous_filename? // "") ][] | + ascii_downcase | test("^src-tauri/(crates|src)/|^src/|^docs/(tally|agent)/|^scripts/(bank_statement_import|sanitise-bbox-capture)|(^|/)[^/]*(dsc|credential|tally)[^/]*(/|$)")) + ' <<<"$files") +fi +if [ "$security_sensitive_change" = "true" ]; then + if ! body_section_has_content "$prbody" 'security impact|security implications' true; then + bad "DSC, Tally, or credential path change lacks non-empty security-impact notes" + else + say "ok" "DSC, Tally, or credential path change includes security-impact notes" + fi + +fi +# DSC/credential changes require a separate security-focused reviewer comment. +# A general approval or the PR author’s impact notes are not that record. +security_reviewer_change=false +if [ "$files_status" -eq 0 ]; then + security_reviewer_change=$(jq ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [.filename, (.previous_filename? // "")][] | + test("(^|[/_.-])(dsc|credential[s]?|certificate[s]?|keystore|secret[s]?)([/_.-]|$)"; "i")) + ' <<<"$files") +fi +if [ "$security_reviewer_change" = "true" ]; then + pr_author=$(jq -er '.user.login | strings | select(length > 0)' <<<"$metadata_pr" 2>/dev/null) || pr_author="" + security_review=false + if [ -n "$pr_author" ] && [ "$review_status" -eq 0 ] && [ "$comment_status" -eq 0 ]; then + printf '%s' "$reviews" >"$tmpdir/reviews.json" + printf '%s' "$comments" >"$tmpdir/comments.json" + security_review=$(jq -n --arg head "$head" --arg author "$pr_author" --slurpfile reviews "$tmpdir/reviews.json" --slurpfile comments "$tmpdir/comments.json" ' + def records: if all(.[]; type == "array") then flatten else . end; + def source_records: if length == 1 then .[0] else . end; + def reviewer: (.user.login | type == "string" and length > 0) and + .user.login != $author and (.user.type == "User" or .user.type == "Bot") and + ((.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") or + (.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot")); + def focused: (.body | type == "string") and + (.body | test("(?im)^#{0,6} *security review: *" + $head + " *$")) and + (.body | test("(?im)^result: *accepted *$")); + ([($reviews | source_records | records)[] | select(reviewer and focused and .commit_id == $head and + (.state == "APPROVED" or .state == "COMMENTED"))] + + [($comments | source_records | records)[] | select(reviewer and focused)]) | length > 0 + ' 2>/dev/null) || security_review=false + fi + if [ "$security_review" != "true" ]; then + unknown "DSC or credential change lacks a separate current-head security-focused reviewer comment" + else + say "ok" "separate security-focused reviewer comment names full current head $short" + fi +fi +if [ "$workflow_change" = "true" ]; then + if ! body_section_has_content "$prbody" 'rollback notes|rollback procedure|migration/sync compatibility and rollback procedure'; then + bad "workflow change lacks non-empty rollback notes" + fi + if ! body_section_has_content "$prbody" 'migration compatibility|migration impact|migration/sync compatibility'; then + bad "workflow change lacks non-empty migration compatibility notes" + fi +fi +if [ "$native_frontend_change" = "true" ] && ! body_section_has_content "$prbody" 'migration compatibility|migration impact|migration/sync compatibility'; then + bad "native or frontend change lacks non-empty migration compatibility notes" +fi +if [ "$migration_change" = "true" ] && ! body_section_has_content "$prbody" 'rollback notes|rollback procedure|migration/sync compatibility and rollback procedure'; then + bad "database migration path lacks non-empty rollback notes" +fi +if [ "$implementation_code_added" = "true" ] && ! body_has_p4_answers "$prbody"; then + bad "implementation code addition lacks all three substantive P4 reuse, deletion, and omission answers" +fi +if [ "$platform_sensitive_change" = "true" ]; then + if ! body_has_platform_evidence "$prbody" windows; then + bad "platform-sensitive change lacks substantive Windows validation evidence or unaffected-host rationale" + fi + if ! body_has_platform_evidence "$prbody" macos; then + bad "platform-sensitive change lacks substantive macOS validation evidence or unaffected-host rationale" + fi +fi + +# Read and validate the v1 surface as a required object. Any transport, +# decoding, JSON, or schema failure is indeterminate; an unrelated nested +# `path` must not turn an incomplete manifest into an empty pin set. Both the +# reviewed head and captured base tip are checked: a head surface that silently +# drops a previously pinned path is a human hold, and changed paths are tested +# against the union so an unpinned head cannot hide a reseal obligation. +SURFACE="docs/tally/compatibility/compatibility-surface.json" +read_surface_paths() { + local ref="$1" + local response content decoded decode_status + surface_paths_result="" + : >"$errfile" + response=$(gh api "repos/$REPO/contents/$SURFACE?ref=$ref" 2>"$errfile") || return 1 + content=$(jq -er 'select(.encoding == "base64") | .content | strings' <<<"$response") || return 1 + decoded="" + decode_status=0 + decoded=$(printf '%s' "${content//$'\n'/}" | base64 --decode 2>"$errfile") || decode_status=$? + if [ "$decode_status" -ne 0 ]; then + decode_status=0 + decoded=$(printf '%s' "${content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? + fi + if [ "$decode_status" -ne 0 ] || ! jq -e ' + type == "object" and + .schema_version == 1 and + ((.manifest_sha256 | type) == "string") and (.manifest_sha256 | test("^[0-9a-f]{64}$")) and + (.files | type == "array" and length > 0 and + all(.[]; type == "object" and + ((.path | type) == "string") and (.path | length > 0) and + ((.sha256 | type) == "string") and (.sha256 | test("^[0-9a-f]{64}$")))) and + (([.files[].path] | length) == ([.files[].path] | unique | length)) + ' <<<"$decoded" >/dev/null 2>&1; then + return 1 + fi + surface_paths_result=$(jq -r '.files[].path' <<<"$decoded") +} + +head_surface_status=0 +read_surface_paths "$head" || head_surface_status=$? +if [ "$head_surface_status" -ne 0 ]; then + unknown "could not read and validate compatibility surface at $short" + pinned="" +else + pinned="$surface_paths_result" + say "ok" "validated v1 compatibility surface at head $short" +fi + +base_surface_status=0 +if [ -n "$base_tip" ]; then + read_surface_paths "$base_tip" || base_surface_status=$? +fi +if [ -n "$base_tip" ] && [ "$base_surface_status" -ne 0 ]; then + unknown "could not read and validate compatibility surface at base ${base_tip:0:7}" + base_pinned="" +elif [ -n "$base_tip" ]; then + base_pinned="$surface_paths_result" + say "ok" "validated v1 compatibility surface at base ${base_tip:0:7}" +else + base_pinned="" +fi + +if [ -n "$changed" ] && [ -n "$pinned" ] && [ -n "$base_pinned" ]; then + removed_pins=$(comm -23 <(sort -u <<<"$base_pinned") <(sort -u <<<"$pinned")) + if [ -n "$removed_pins" ]; then + removed_count=$(wc -l <<<"$removed_pins" | tr -d ' ') + unknown "$removed_count base-pinned path(s) are absent from the head surface; human review is required" + fi + union_pinned=$(printf '%s\n%s\n' "$base_pinned" "$pinned" | sort -u) + touched=$(comm -12 <(sort -u <<<"$union_pinned") <(sort -u <<<"$changed") | awk -v surface="$SURFACE" '$0 != surface') + if [ -z "$touched" ]; then + say "ok" "changed files contain no pinned path requiring a reseal" + elif grep -Fxq "$SURFACE" <<<"$changed"; then + say "ok" "changed pinned paths include the compatibility surface" + else + bad "changed pinned paths omit the compatibility surface reseal" + fi +fi + +# Scan destination paths and added payload lines. Removed/context lines never +# enter the privacy scan. Binary additions are an explicit human-inspection +# hold because their bytes are absent from a textual patch. +: >"$errfile" +diff_status=0 +diff=$(gh pr diff "$PR" --repo "$REPO" 2>"$errfile") || diff_status=$? +if [ "$diff_status" -ne 0 ] || [ -z "$diff" ]; then + unknown "could not read diff for the privacy scan" +else + # Parse Git's C-style quoted paths with the existing Python runtime. The + # shell receives only JSON/TSV data after the parser has matched every diff + # section, so non-ASCII destinations retain their REST filename identity. + diff_stats="$tmpdir/diff-stats.tsv" + added_payload="$tmpdir/added-payload" + parsed_diff_status=0 + parsed_diff=$(python3 scripts/merge_gate_diff.py <<<"$diff") || parsed_diff_status=$? + if [ "$parsed_diff_status" -ne 0 ] || ! jq -e ' + type == "object" and + (.records | type == "array" and all(.[]; type == "object" and + (.destination | type == "string" and length > 0) and + ((.textual_destination == null) or (.textual_destination | type == "string" and length > 0)) and + (.added | type == "number" and floor == . and . >= 0) and + (.deleted | type == "number" and floor == . and . >= 0) and + (.binary | type == "boolean") and (.gitlink | type == "boolean"))) and + (.added_payload | type == "array" and all(.[]; type == "string")) + ' <<<"$parsed_diff" >/dev/null 2>&1; then + unknown "could not parse diff sections for the privacy scan" + : >"$diff_stats" + : >"$added_payload" + else + jq -r '.records[] | [.destination, .added, .deleted, (if .textual_destination == null then 0 else 1 end), (if .binary then 1 else 0 end), (if .gitlink then 1 else 0 end)] | @tsv' <<<"$parsed_diff" >"$diff_stats" + jq -r '.added_payload[]' <<<"$parsed_diff" >"$added_payload" + + + coverage_count=0 + coverage_examples="" + metadata_only_count=0 + metadata_only_examples="" + binary_count=0 + gitlink_count=0 + bounded_coverage_name() { + local item + item=$(sed -E 's#/(Users|home)/[^/[:space:]]+##g; s#[A-Za-z]:[\\/]+Users[\\/]+[^\\/[:space:]]+##g' <<<"$1") + printf '%s' "${item:0:160}" + } + record_coverage_issue() { + coverage_count=$((coverage_count + 1)) + if [ "$coverage_count" -le 8 ]; then + local item + item=$(bounded_coverage_name "$1") + coverage_examples="${coverage_examples}${coverage_examples:+; }$item" + fi + } + record_metadata_only() { + metadata_only_count=$((metadata_only_count + 1)) + if [ "$metadata_only_count" -le 8 ]; then + metadata_only_examples="${metadata_only_examples}${metadata_only_examples:+; }$(bounded_coverage_name "$1")" + fi + } + path_text="" + if [ -s "$changed_records" ]; then + while IFS=$'\t' read -r filename status rest_added rest_deleted previous_filename; do + [ "$status" = "removed" ] && continue + match_count=$(awk -F '\t' -v filename="$filename" '$1 == filename { count++ } END { print count+0 }' "$diff_stats") + if [ "$match_count" -ne 1 ]; then + record_coverage_issue "omits or duplicates '$filename'" + continue + fi + diff_record=$(awk -F '\t' -v filename="$filename" '$1 == filename { print; exit }' "$diff_stats") + IFS=$'\t' read -r _ diff_added diff_deleted textual binary gitlink <<<"$diff_record" + if [ "$gitlink" -eq 1 ]; then + gitlink_count=$((gitlink_count + 1)) + fi + if [ "$binary" -eq 1 ]; then + # Its bytes cannot be reconciled through textual hunks. + binary_count=$((binary_count + 1)) + continue + fi + if [ "$textual" -ne 1 ] && [ "$diff_added" -eq 0 ] && [ "$diff_deleted" -eq 0 ]; then + if [ "$rest_added" -eq 0 ] && [ "$rest_deleted" -eq 0 ]; then + record_metadata_only "$filename" + else + record_coverage_issue "metadata-only '$filename' conflicts with REST line totals" + fi + elif [ "$textual" -ne 1 ]; then + record_coverage_issue "lacks a textual destination for '$filename'" + elif [ "$diff_added" -ne "$rest_added" ] || [ "$diff_deleted" -ne "$rest_deleted" ]; then + record_coverage_issue "line totals for '$filename' differ from REST metadata" + fi + done <"$changed_records" + [ "$binary_count" -eq 0 ] || bad "$binary_count binary addition/change(s) require human privacy inspection" + [ "$gitlink_count" -eq 0 ] || unknown "$gitlink_count gitlink change(s) require explicit provenance, license, and NOTICE review" + if [ "$coverage_count" -gt 0 ]; then + unknown "privacy diff coverage failed for $coverage_count non-removed REST file(s): $coverage_examples" + fi + if [ "$metadata_only_count" -gt 0 ]; then + say "note" "$metadata_only_count metadata-only diff section(s) have REST 0/0 totals: $metadata_only_examples" + fi + # Destination paths are scan input from the complete REST set, not only + # from whatever textual patch GitHub happened to render. Removed paths + # carry no newly added material and are deliberately excluded. + path_text=$(awk -F '\t' '$2 != "removed" { print $1 }' "$changed_records") + fi + added=$(cat "$added_payload") + scan_input="$privacy_metadata +$path_text +$added" + # Diagnostic counts only: never echo matched home paths, which could repeat + # the private value in a merge-gate result. + home_path_status=0 + mac_home='/'"Users"'/[A-Za-z0-9._-]+' + unix_home='/'"home"'/[A-Za-z0-9._-]+' + windows_home='[A-Za-z]:[\\/]{1,2}'"Users"'[\\/]{1,2}[A-Za-z0-9._-]+' + home_path_matches=$(grep -Eio "(^|[^[:alnum:]_])(${mac_home}|${unix_home}|${windows_home})(\$|/|\\\\|[^[:alnum:]_.-])" <<<"$scan_input") || home_path_status=$? + if [ "$home_path_status" -gt 1 ]; then + unknown "developer-home path scan expression failed" + elif [ "$home_path_status" -eq 0 ]; then + home_path_count=$(grep -Ec '.' <<<"$home_path_matches") + bad "privacy scan found $home_path_count developer-home path shape(s)" + fi + redacted=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g; s/[0-9a-fA-F]{32,}//g' <<<"$scan_input") + exempt=$(grep -Ec '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32,}' <<<"$scan_input") + [ "$exempt" -eq 0 ] || say "note" "$exempt added/path line(s) carried generated UUID/digest shapes; inspect those lines" + placeholder='^(X+|Z+)[0-9]+(X|Z)?$|^[0-9]{2}(X+|Z+)[0-9]+[0-9A-Z]*$' + if printf '%s\n' 'XXXXX1234X' | grep -qE "$placeholder"; then :; else + probe_status=$? + if [ "$probe_status" -eq 1 ]; then + bad "privacy placeholder control did not match its synthetic probe" + else + unknown "privacy placeholder expression failed" + fi + fi + count_nonplaceholder() { + local pattern="$1" input="$2" matches="" status=0 item count=0 + if matches=$(grep -Eio "$pattern" <<<"$input"); then + : + else + status=$? + if [ "$status" -eq 1 ]; then matches=""; else return 2; fi + fi + while IFS= read -r item; do + [ -n "$item" ] || continue + item=$(tr '[:lower:]' '[:upper:]' <<<"$item") + if printf '%s\n' "$item" | grep -qE "$placeholder"; then + : + else + status=$? + [ "$status" -eq 1 ] && count=$((count + 1)) || return 2 + fi + done <<<"$matches" + printf '%s\n' "$count" + } + # Join separators only inside recognised identifier shapes. A global + # separator-free projection fuses unrelated values and creates false + # identifiers, including adjacent date fragments. Alongside mobile numbers, + # accept only 4-4-4 and 4-4-4-4 grouped long-number forms. + phone_status=0 + normalized_status=0 + normalized_whitespace=$(python3 -c 'import sys, unicodedata; print("".join(" " if unicodedata.category(char) == "Zs" else char for char in sys.stdin.read()), end="")' <<<"$redacted") || normalized_status=$? + if [ "$normalized_status" -ne 0 ]; then + unknown "Unicode whitespace normalization failed" + normalized_whitespace="" + fi + phone_matches=$(grep -Eo '(^|[^[:alnum:]])[6-9]([ ()+._-]{0,3}[0-9]){9}([^[:alnum:]]|$)' <<<"$normalized_whitespace") || phone_status=$? + grouped_number_status=0 + grouped_number_matches=$(grep -Eo '(^|[^[:alnum:]])[0-9]{4}([ ._-])[0-9]{4}\2[0-9]{4}(\2[0-9]{4})?([^[:alnum:]]|$)' <<<"$redacted") || grouped_number_status=$? + if [ "$normalized_status" -ne 0 ] || [ "$phone_status" -gt 1 ] || [ "$grouped_number_status" -gt 1 ]; then + unknown "formatted identifier scan expression failed" + fi + normalized_phone=$(sed -E 's/[^0-9]//g' <<<"$phone_matches") + normalized_grouped_numbers=$(sed -E 's/[^0-9]//g' <<<"$grouped_number_matches") + scan_shapes="$redacted +$normalized_phone +$normalized_grouped_numbers" + hits_status=0 + hits=$(count_nonplaceholder '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' "$scan_shapes") || hits_status=$? + runs_status=0 + runs=$(count_nonplaceholder '[0-9]{11,18}' "$scan_shapes") || runs_status=$? + if [ "$hits_status" -ne 0 ] || [ "$runs_status" -ne 0 ]; then + unknown "privacy scan expression failed" + elif [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then + say "ok" "PR metadata, destination paths, and payload lines carry no identifier shapes" + else + bad "privacy scan found $hits identifier shape(s) and $runs unexplained long digit run(s)" + fi + fi +fi + +# Re-read all moving identities immediately before emitting a merge command. +: >"$errfile" +final_meta_status=0 +final_meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,title,body,changedFiles 2>"$errfile") || final_meta_status=$? +if [ "$final_meta_status" -ne 0 ] || ! jq -e 'type == "object" and (.headRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and (.baseRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and (.baseRefName | type == "string") and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and (.state | type == "string") and (.title | type == "string") and (.changedFiles | type == "number" and floor == . and . >= 0)' <<<"$final_meta" >/dev/null 2>&1; then + unknown "could not revalidate PR head and base before merge" +else + final_head=$(jq -r '.headRefOid' <<<"$final_meta") + final_base_ref_oid=$(jq -r '.baseRefOid' <<<"$final_meta") + final_base=$(jq -r '.baseRefName' <<<"$final_meta") + final_mergeable=$(jq -r '.mergeable' <<<"$final_meta") + final_state=$(jq -r '.mergeStateStatus' <<<"$final_meta") + final_draft=$(jq -r '.isDraft' <<<"$final_meta") + final_pstate=$(jq -r '.state' <<<"$final_meta") + final_title=$(jq -r '.title' <<<"$final_meta") + final_changed_files=$(jq -r '.changedFiles' <<<"$final_meta") + [ "$final_head" = "$head" ] || bad "PR head moved during preflight" + [ "$final_base_ref_oid" = "$base_ref_oid" ] || bad "PR base OID moved during preflight" + [ "$final_base" = "$base" ] || bad "PR base moved during preflight" + [ "$final_mergeable" = "MERGEABLE" ] || bad "PR mergeability changed to $final_mergeable during preflight" + [ "$final_draft" = "false" ] || bad "PR became draft during preflight" + [ "$final_pstate" = "OPEN" ] || bad "PR state changed to $final_pstate during preflight" + [ "$final_changed_files" = "$changed_files_expected" ] || bad "PR changed-file count moved during preflight" + case "$final_state" in + CLEAN|HAS_HOOKS) : ;; + BEHIND|DIRTY|UNKNOWN|BLOCKED|UNSTABLE|DRAFT) bad "PR merge state changed to $final_state during preflight" ;; + *) unknown "PR merge state changed to unrecognised value '$final_state' during preflight" ;; + esac + raw_final_body=$(jq -r '.body // ""' <<<"$final_meta") + final_body="$raw_final_body" + final_visible_status=0 + final_body=$(python3 -c 'import re, sys +text = sys.stdin.read() +print(re.sub(r"|\Z)", "", text, flags=re.S), end="")' <<<"$final_body") || final_visible_status=$? + [ "$final_visible_status" -eq 0 ] || unknown "could not extract final visible PR description content" + [ "$final_title" = "$title" ] || bad "PR title changed during preflight" + if ! checklist_link_ok "$final_body" "$review_checklist"; then + bad "PR description changed and no longer carries a completed same-repository line-specific checklist link" + fi + if [ "$raw_final_body" != "$raw_prbody" ]; then + bad "PR description changed during preflight; re-run metadata privacy scan" + if ! body_section_has_content "$final_body" 'functional summary|outcome and reason'; then + bad "PR description changed and no longer carries a non-empty functional summary" + fi + if ! body_section_has_content "$final_body" 'test or reproduction command|commands and results|validation and evidence'; then + bad "PR description changed and no longer carries test or reproduction evidence" + fi + if ! body_has_validation_command "$final_body"; then + bad "PR description changed and no longer carries an actual test or reproduction command" + fi + fi +fi +if [ -n "$base_tip" ]; then + : >"$errfile" + final_base_tip_status=0 + final_base_tip=$(gh api "repos/$REPO/branches/$base" --jq '.commit.sha' 2>"$errfile") || final_base_tip_status=$? + if [ "$final_base_tip_status" -ne 0 ] || ! [[ "$final_base_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + unknown "could not revalidate base tip before merge" + elif [ "$final_base_tip" != "$base_tip" ]; then + bad "base tip moved during preflight" + fi +fi + +echo +if [ "$fail" -ne 0 ]; then + echo "MUST NOT MERGE" + exit 1 +fi +if [ "$uncertain" -ne 0 ]; then + echo "INDETERMINATE — do not merge until the missing evidence is obtained" + exit 2 +fi + +echo "MAY MERGE — bind the merge to the reviewed head and validated base:" +printf ' [ "$(gh pr view %s --repo %s --json baseRefName -q .baseRefName)" = "%s" ] \\\n && gh pr merge %s --repo %s --squash --match-head-commit %s\n' \ + "$PR" "$REPO" "$base" "$PR" "$REPO" "$head" +exit 0 diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py new file mode 100644 index 000000000..094f9d4da --- /dev/null +++ b/scripts/merge-gate.test.py @@ -0,0 +1,1154 @@ +#!/usr/bin/env python3 +"""Focused offline controls for scripts/merge-gate.sh. + +The fake gh command models server responses, including paginated REST and +GraphQL pages. No network or merge operation is used. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "merge-gate.sh" +HEAD = "0123456789abcdef0123456789abcdef01234567" +FAKE_GH = r'''#!/usr/bin/env python3 +import base64, json, os, sys +args = sys.argv[1:] +scenario = os.environ.get("GATE_SCENARIO", "pass") +security_case = scenario.startswith("security-review-") +sync_case = scenario.startswith("sync-") +head = "0123456789abcdef0123456789abcdef01234567" +new_head = "fedcba9876543210fedcba9876543210fedcba98" +base = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +def emit(value): + if value is not None: + print(value if isinstance(value, str) else json.dumps(value)) + +def fail(message="controlled API failure"): + print(message, file=sys.stderr) + raise SystemExit(1) + +if args[:2] == ["pr", "view"]: + counter_path = os.environ.get("GATE_COUNTER") + view_count = 0 + if counter_path: + try: + view_count = int(open(counter_path).read()) + except (FileNotFoundError, ValueError): + pass + with open(counter_path, "w") as counter: + counter.write(str(view_count + 1)) + selected_head = new_head if scenario == "head-moves" and view_count > 0 else head + final_state = "UNKNOWN_VALUE" if scenario == "final-unrecognized" and view_count > 0 else ("BLOCKED" if scenario == "blocked-state" else ("DRAFT" if scenario in {"draft-state", "draft-surface-fail"} else "CLEAN")) + title = "Safe merge gate control" + if scenario == "metadata-title-id": + title = "Customer " + "ABCDE" + "1234" + "F" + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + if scenario == "body-loses-evidence" and view_count > 0: + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario == "body-loses-functional" and view_count > 0: + body = ( + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + if scenario == "checklist-foreign": + body = "- [x] [Errors](https://github.com/other/repo/blob/HEAD/review-checklist.md#L10)" + elif scenario == "checklist-unlinked": + body = "- [x] review-checklist.md line 10" + elif scenario == "checklist-template-continuation": + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "- [x] One completed [`review-checklist.md`](../review-checklist.md) line is\n" + " linked here: https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10" + ) + elif scenario == "missing-functional-summary": + body = ( + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario == "template-functional-prompt": + body = ( + "## Outcome and reason\n\nWhat concrete user or maintainer workflow changes, and why now?\n\n" + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario in {"template-validation-prompt", "template-validation-sha-only"}: + sha = "deadbeef" if scenario == "template-validation-sha-only" else "" + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + f"## Validation and evidence\n\n- Exact candidate SHA: {sha}\n" + "- Commands and results (`corepack pnpm ...`, `cargo ...`, or reproduction):\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario == "template-validation-empty": + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "## Validation and evidence\n\nCommands and results are recorded elsewhere.\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario in {"workflow-notes-present", "workflow-delete-notes", "workflow-rename-out-notes"}: + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "## Rollback notes\n\nRevert the workflow commit.\n\n" + "## Migration compatibility\n\nNo persisted data changes.\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario in {"security-notes-present", "security-none", "security-pending", "surface-unpins"}: + security_impact = "None" if scenario == "security-none" else ("pending" if scenario == "security-pending" else "No credential material is added; the Tally path change is reviewed.") + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + f"## Security impact\n\n{security_impact}\n\n" + "## Migration compatibility\n\nExisting callers retain their paths and formats.\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + elif scenario == "checklist-missing-anchor": + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "## Validation and evidence\n\n`python3 scripts/merge-gate.test.py`\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L999999)" + ) + elif scenario == "missing-test-summary": + body = ( + "## Outcome and reason\n\nA bounded merge preflight keeps incomplete evidence from becoming a merge.\n\n" + "- [x] [Errors](https://github.com/lamemustafa/bridge/blob/HEAD/review-checklist.md#L10)" + ) + if security_case or sync_case: + body += "\n## Security impact\n\nCredential admission remains enforced.\n" + if not scenario.endswith("migration-missing"): + body += "\n## Migration compatibility\n\nNo persisted format change.\n" + if scenario == "validation-command-outside": + body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run") + "\n## Rollback\n`cargo test`\n" + if scenario == "policy-multiline-comment": + body = body.replace("A bounded merge preflight keeps incomplete evidence from becoming a merge.", "") + if scenario == "validation-html-comment": + body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run ") + if scenario == "unterminated-html-comment": + body = body.replace("`python3 scripts/merge-gate.test.py`", "" + if scenario == "body-comment-drift" and view_count > 0: + body += "\n" + if scenario == "validation-tool-prose": + body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run; cargo is available") + if scenario == "validation-placeholder-command": + body = body.replace("`python3 scripts/merge-gate.test.py`", "`cargo ...`") + if scenario == "checklist-heading": + body = body.replace("#L10", "#L1") + if scenario == "checklist-anchor-suffix": + body = body.replace("#L10", "#L10junk") + if scenario in {"implementation-p4-present", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-present", "migration-template-wrapped", "security-notes-present", "security-none", "security-pending", "security-review-valid"}: + body += ( + "\n## Scope, reuse, and impact\n\n" + "- Existing component reused: the existing gate parser and file inventory.\n" + "- What is deleted (or why no deletion is justified): no duplicate path remains.\n" + "- What breaks if this is not built: unsafe evidence could reach a merge.\n" + ) + if security_case or scenario in {"surface-unpins", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: + body += ( + "\n## Scope, reuse, and impact\n\n" + "- Existing component reused: the existing gate parser and file inventory.\n" + "- What is deleted (or why no deletion is justified): no duplicate path remains.\n" + "- What breaks if this is not built: unsafe evidence could reach a merge.\n" + "\n## Security impact\n\nNo credential material is added.\n" + "\n## Migration compatibility\n\nExisting callers retain their paths and formats.\n" + "\n- Windows validation evidence: Windows CI ran `python3 scripts/merge-gate.test.py`.\n" + "- macOS validation evidence: macOS CI ran `python3 scripts/merge-gate.test.py`.\n" + ) + if scenario in {"platform-evidence-present", "migration-template-wrapped", "security-notes-present", "security-none", "security-pending", "security-review-valid", "sync-migration-present"}: + body += ( + "\n- Windows validation evidence: Windows CI ran `python3 scripts/merge-gate.test.py`.\n" + "- macOS validation evidence: macOS CI ran `python3 scripts/merge-gate.test.py`.\n" + ) + if scenario == "platform-checkbox-evidence": + body += ( + "\n- [x] Native Windows validation completed: `python3 scripts/merge-gate.test.py` passed on Windows CI.\n" + "- [x] Native macOS validation completed: `python3 scripts/merge-gate.test.py` passed on macOS CI.\n" + ) + if scenario == "platform-checkbox-comment": + body += ( + "\n- [x] Native Windows validation completed: \n" + "- [x] Native macOS validation completed: \n" + ) + if scenario == "platform-unaffected-bare": + body += "\n- Windows validation evidence: unaffected\n- macOS validation evidence: unaffected\n" + if scenario == "platform-unaffected-rationale": + body += "\n- Windows validation evidence: unaffected because this changes shared documentation only.\n- macOS validation evidence: unaffected because this changes shared documentation only.\n" + if scenario in {"platform-evidence-present", "platform-checkbox-evidence", "platform-unaffected-bare", "platform-unaffected-rationale"}: + body += ( + "\n## Security impact\n\nNo credential material is added.\n" + "\n## Migration compatibility\n\nExisting callers retain their paths and formats.\n" + ) + if scenario == "migration-rollback-present": + body += "\n## Rollback notes\n\nRevert the migration commit before deployment.\n" + if scenario == "migration-template-wrapped": + body += ( + "\n- Migration/sync compatibility and rollback procedure (required when an\n" + " existing workflow changes): Existing readers retain the old format; revert this commit before deployment.\n" + ) + if scenario == "migration-template-other-field": + body += ( + "\n- Migration/sync compatibility and rollback procedure (required when an\n" + " existing workflow changes):\n" + "- Destructive database migration: No\n" + ) + if scenario == "p4-placeholders": + body += ( + "\n## Scope, reuse, and impact\n\n" + "- Existing component reused: N/A\n" + "- What is deleted (or why no deletion is justified): TODO\n" + "- What breaks if this is not built: pending\n" + ) + if scenario == "workflow-placeholders": + body += "\n## Rollback notes\n\nN/A\n\n## Migration compatibility\n\nTBD\n" + body = body.replace("blob/HEAD", f"blob/{head}") + if scenario == "checklist-stale-ref": + body = body.replace(f"blob/{head}", "blob/" + "f" * 40) + one_file = scenario in {"metadata-private", "files-empty", "formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "implementation-p4-powershell", "implementation-p4-sql", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case + selected_base = new_head if scenario == "base-oid-mismatch" else base + emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", + "mergeable": "MERGEABLE", "mergeStateStatus": final_state, + "isDraft": False, "state": "OPEN", "title": title, + "body": body, "changedFiles": 1 if one_file else 2}) +elif args[:2] == ["pr", "checks"]: + if scenario == "checks-silent": + raise SystemExit(0) + if scenario == "cancel-check": + emit([{"bucket": "cancel", "name": "Required checks"}]) + elif scenario == "required-skip": + emit([{"bucket": "skipping", "name": "Required checks"}, {"bucket": "pass", "name": "Rust format"}]) + elif scenario == "missing-required": + emit([{"bucket": "pass", "name": "Required checks"}]) + else: + emit([{"bucket": "pass", "name": "Frontend build"}, + {"bucket": "pass", "name": "Rust format"}, + {"bucket": "pass", "name": "GitGuardian Security Checks"}, + {"bucket": "pass", "name": "Dependency security"}, + {"bucket": "pass", "name": "Required checks"}, + {"bucket": "skipping", "name": "Optional documentation"}]) +elif args[:2] == ["pr", "diff"]: + if security_case: + emit("diff --git a/src-tauri/src/dsc.rs b/src-tauri/src/dsc.rs\n--- a/src-tauri/src/dsc.rs\n+++ b/src-tauri/src/dsc.rs\n@@ -0,0 +1 @@\n+safe check\n") + elif sync_case: + emit("diff --git a/src-tauri/src/sync.rs b/docs/retired.rs\nsimilarity index 100%\nrename from src-tauri/src/sync.rs\nrename to docs/retired.rs\n") + elif scenario == "formatted-phone": + phone = "+91 " + "98765" + "-43210" + emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+Call {phone}\n") + elif scenario == "path-id": + path_id = "ABCDE" + "1234" + "F" + emit(f"diff --git a/docs/safe.md b/docs/{path_id}.md\n--- a/docs/safe.md\n+++ b/docs/{path_id}.md\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"security-notes-missing", "security-notes-present", "security-none", "security-pending"}: + emit("diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs\n--- a/src-tauri/src/tally/runtime.rs\n+++ b/src-tauri/src/tally/runtime.rs\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"security-crate", "security-agent-import", "security-dsc"}: + paths = {"security-crate": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", "security-agent-import": "src-tauri/src/agent_import.rs", "security-dsc": "src-tauri/src/dsc.rs"} + emit(f"diff --git a/{paths[scenario]} b/{paths[scenario]}\n--- a/{paths[scenario]}\n+++ /dev/null\n@@ -1 +0,0 @@\n-safe text\n") + elif scenario == "security-rename-out": + emit("diff --git a/src-tauri/src/tally/runtime.rs b/src/runtime.rs\nsimilarity index 100%\nrename from src-tauri/src/tally/runtime.rs\nrename to src/runtime.rs\n") + elif scenario.startswith("home-"): + homes = {"home-macos": "/" + "Users" + "/" + "tester" + "/work", "home-unix": "/" + "home" + "/" + "tester" + "/work", "home-windows": "C:" + "\\" + "Users" + "\\" + "tester" + "\\work", "home-macos-root": "/" + "Users" + "/" + "tester", "home-unix-root": "/" + "home" + "/" + "tester", "home-windows-forward": "C:" + "/" + "Users" + "/" + "tester" + "/work", "home-windows-escaped": "C:" + "\\\\" + "Users" + "\\\\" + "tester" + "\\\\work", "home-regex-source": "mac_home=" + "'/'" + '"Users"' + "'/[A-Za-z0-9._-]+'"} + emit(f"diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+{homes[scenario]}\n") + elif scenario == "binary-delete": + emit("diff --git a/docs/old.png b/docs/old.png\nBinary files a/docs/old.png and /dev/null differ\n") + elif scenario == "diff-omits-file": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario == "diff-truncated-payload": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +2 @@\n+first line\n") + elif scenario == "formatted-phone-grouped": + phone = "6" + "98 765-4321" + emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic {phone}\n") + elif scenario == "repeated-phone": + phone = "6" + "6" * 9 + emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic {phone}\n") + elif scenario in {"grouped-identifier-12", "grouped-identifier-16"}: + identifier = "8421 " + "7654 9012" if scenario.endswith("12") else "8421-" + "7654-9012-3456" + emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic {identifier}\n") + elif scenario in {"workflow-notes-missing", "workflow-notes-present"}: + emit("diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n--- a/.github/workflows/ci.yml\n+++ b/.github/workflows/ci.yml\n@@ -0,0 +1 @@\n+safe workflow text\n") + elif scenario in {"workflow-delete", "workflow-delete-notes"}: + emit("diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n--- a/.github/workflows/ci.yml\n+++ /dev/null\n@@ -1 +0,0 @@\n-safe workflow text\n") + elif scenario in {"workflow-rename-out", "workflow-rename-out-notes"}: + emit("diff --git a/.github/workflows/ci.yml b/docs/retired-ci.yml\nsimilarity index 100%\nrename from .github/workflows/ci.yml\nrename to docs/retired-ci.yml\n") + elif scenario in {"phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized"}: + phones = { + "phone-space": "6" + "9876 54321", + "phone-dot": "6" + "987.654.321", + "phone-plus": "6" + "9876+54321", + "phone-underscore": "6" + "987_654_321", + "phone-parenthesized": "(" + "69876" + ") 54321", + } + emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic {phones[scenario]}\n") + elif scenario == "unicode-phone": + emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic 69876\u00a054321\n") + elif scenario == "unicode-phone-two-lines": + emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +2 @@\n+69876\n+54321\n") + elif scenario == "metadata-only": + emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") + elif scenario == "metadata-private": + private_path = "docs/" + "/" + "Users" + "/tester/" + "x" * 300 + emit(f"diff --git a/{private_path} b/{private_path}\nsimilarity index 100%\nrename from {private_path}\nrename to {private_path}\n") + elif scenario == "metadata-incomplete": + emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") + elif scenario == "hunk-header-phone": + phone = "6" + "9876" + "54321" + emit(f"diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+++ b/synthetic {phone}\n") + elif scenario == "hunk-header-literals": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +2 @@\n+++ b/safe\n+++ /dev/null\n") + elif scenario == "hunk-binary-literal": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+GIT binary patch\n") + elif scenario == "all-a-pan": + identifier = "AAAAA" + "1234" + "A" + emit(f"diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+{identifier}\n") + elif scenario == "masked-pan": + identifier = "XXXXX" + "1234" + "X" + emit(f"diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+{identifier}\n") + elif scenario == "quoted-path": + emit('diff --git "a/docs/caf\\303\\251.md" "b/docs/caf\\303\\251.md"\n--- "a/docs/caf\\303\\251.md"\n+++ "b/docs/caf\\303\\251.md"\n@@ -0,0 +1 @@\n+safe text\n') + elif scenario == "crlf-diff": + emit("diff --git a/docs/example.md b/docs/example.md\r\n--- a/docs/example.md\r\n+++ b/docs/example.md\r\n@@ -0,0 +1 @@\r\n+safe text\r\n") + elif scenario == "ambiguous-unquoted-path": + emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\n--- a/docs/a b/example.md\n+++ b/docs/a b/example.md\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario == "ambiguous-rename-path": + emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\nsimilarity index 100%\nrename from docs/a b/example.md\nrename to docs/a b/example.md\n") + elif scenario == "gitlink": + emit("diff --git a/vendor/module b/vendor/module\nnew file mode 160000\nindex 0000000..2222222\n--- /dev/null\n+++ b/vendor/module\n@@ -0,0 +1 @@\n+Subproject commit 2222222\n") + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "implementation-p4-powershell", "implementation-p4-sql", "p4-placeholders"}: + suffix = {"implementation-p4-shell": "sh", "implementation-p4-powershell": "ps1", "implementation-p4-sql": "sql"}.get(scenario, "py") + emit(f"diff --git a/scripts/example.{suffix} b/scripts/example.{suffix}\n--- a/scripts/example.{suffix}\n+++ b/scripts/example.{suffix}\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: + emit("diff --git a/src-tauri/src/local_files/paths.rs b/src-tauri/src/local_files/paths.rs\n--- a/src-tauri/src/local_files/paths.rs\n+++ b/src-tauri/src/local_files/paths.rs\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: + emit("diff --git a/src-tauri/migrations/001.sql b/src-tauri/migrations/001.sql\n--- a/src-tauri/migrations/001.sql\n+++ b/src-tauri/migrations/001.sql\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario == "separated-dates": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+2026-09-12 2026-09-13\n") + elif scenario == "separated-dates-new-year": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+0101-2026 0201-2026\n") + elif scenario == "separated-dates-year-month": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+2025-09-11 2025-09-12\n") + elif scenario == "adr-identifier": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+CE_ADR_0016_E\n") + elif scenario == "surface-unpins": + emit("diff --git a/src/example.rs b/src/example.rs\n--- a/src/example.rs\n+++ b/src/example.rs\n@@ -0,0 +1 @@\n+safe text\n" + "diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json\n" + "--- a/docs/tally/compatibility/compatibility-surface.json\n+++ b/docs/tally/compatibility/compatibility-surface.json\n@@ -0,0 +1 @@\n+safe manifest\n") + else: + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\ndiff --git a/docs/second.md b/docs/second.md\n--- a/docs/second.md\n+++ b/docs/second.md\n@@ -0,0 +1 @@\n+other text\n") +elif args and args[0] == "api": + joined = " ".join(args) + if "graphql" in args: + def thread_nodes(start, count, unresolved=False): + return [{"id": f"thread-{index}", "isResolved": not (unresolved and index == start)} + for index in range(start, start + count)] + has_cursor = "C1" in joined + if scenario == "threads-empty-more": + nodes, page_info = [], {"hasNextPage": True, "endCursor": "C1"} + elif scenario == "threads-short": + nodes, page_info = thread_nodes(1, 1), {"hasNextPage": False, "endCursor": None} + elif scenario == "threads-malformed-pagination": + nodes, page_info = thread_nodes(1, 100), {"hasNextPage": True, "endCursor": None} + elif has_cursor: + if scenario == "threads-duplicate-id": + nodes = [{"id": "thread-1", "isResolved": True}] + else: + nodes = thread_nodes(101, 1, scenario == "threads-unresolved-second") + page_info = {"hasNextPage": False, "endCursor": None} + else: + nodes, page_info = thread_nodes(1, 100), {"hasNextPage": True, "endCursor": "C1"} + emit({"data": {"repository": {"pullRequest": {"reviewThreads": { + "totalCount": 101.5 if scenario == "threads-fractional" else (102 if scenario == "threads-total-drift" and has_cursor else 101), + "pageInfo": page_info, "nodes": nodes + }}}}}) + elif "/compare/" in joined: + if scenario == "lineage-mismatch": + emit({"status": "diverged", "behind_by": 1, + "merge_base_commit": {"sha": new_head}}) + else: + emit({"status": "ahead", "behind_by": 0, + "merge_base_commit": {"sha": base}}) + elif "/commits/" in joined and "/check-runs" in joined: + if scenario == "check-run-wrong-head": + run_head = new_head + else: + run_head = head + total_count = 3 if scenario == "check-run-count-mismatch" else 2 + second_id = 1 if scenario == "check-run-duplicate-id" else 2 + run_status = "queued" if scenario == "check-run-incomplete" else "completed" + conclusion = "failure" if scenario == "check-run-failed" else ("skipped" if scenario in {"check-run-required-skip", "check-run-optional-skip"} else "success") + run_name = "Optional changed after rollup" if scenario in {"check-run-failed", "check-run-optional-skip"} else "Required checks" + emit([{"total_count": total_count, "check_runs": [ + {"id": 1, "name": run_name, "head_sha": run_head, "status": run_status, "conclusion": conclusion}, + {"id": second_id, "name": "Rust format", "head_sha": run_head, "status": "completed", "conclusion": "success"}]}]) + elif "/commits/" in joined and "/status?" in joined: + def status_page(rows, total_count, state="success", page_head=head): + return {"sha": page_head, "state": state, "total_count": total_count, "statuses": rows} + if scenario == "status-two-page": + emit([status_page([{"id": 1, "context": "legacy one", "state": "success"}], 2), + status_page([{"id": 2, "context": "legacy two", "state": "success"}], 2)]) + elif scenario == "status-truncated": + emit([{"sha": head, "state": "success", "total_count": 1, "statuses": []}]) + elif scenario == "status-duplicate": + row = {"id": 1, "context": "legacy one", "state": "success"} + emit([{"sha": head, "state": "success", "total_count": 2, "statuses": [row]}, + {"sha": head, "state": "success", "total_count": 2, "statuses": [row]}]) + elif scenario == "status-mixed-head": + emit([{"sha": head, "state": "success", "total_count": 2, "statuses": [{"id": 1, "context": "legacy one", "state": "success"}]}, + {"sha": new_head, "state": "success", "total_count": 2, "statuses": [{"id": 2, "context": "legacy two", "state": "success"}]}]) + elif scenario == "status-failed-context": + emit([{"sha": head, "state": "failure", "total_count": 1, "statuses": [{"id": 1, "context": "legacy optional", "state": "failure"}]}]) + elif scenario == "status-nonempty-pending": + emit([{"sha": head, "state": "pending", "total_count": 1, "statuses": [{"id": 1, "context": "queued", "state": "pending"}]}]) + elif scenario == "status-empty-pending": + emit([{"sha": head, "state": "pending", "total_count": 0, "statuses": []}]) + elif scenario == "status-malformed": + emit([{"sha": head, "state": "success", "total_count": "0", "statuses": []}]) + elif scenario == "status-failed-combined": + emit([{"sha": head, "state": "failure", "total_count": 0, "statuses": []}]) + else: + emit([{"sha": head, "state": "success", "total_count": 0, "statuses": []}]) + elif "/pulls/321/commits" in joined: + message = "safe commit metadata" + if scenario == "metadata-commit-id": + message = "Customer " + "ABCDE" + "1234" + "F" + identity = {"name": "Maintainer", "email": "maintainer@example.invalid"} + if scenario == "metadata-author-id": + identity = {"name": "ABCDE" + "1234" + "F", "email": "maintainer@example.invalid"} + linked_author = None if scenario == "metadata-unlinked-identities" else {"login": "author"} + linked_committer = None if scenario == "metadata-unlinked-identities" else {"login": "committer"} + commits = [{"sha": head, "commit": {"message": message, "author": identity, "committer": identity}, + "author": linked_author, "committer": linked_committer}] + if scenario == "metadata-duplicate": + commits.append({"sha": head, "commit": {"message": message}}) + emit([commits]) + elif any(arg.endswith("/pulls/321") for arg in args): + commits = 1 + metadata_head = head + if scenario == "metadata-capped": + commits = 251 + elif scenario in {"metadata-truncated", "metadata-duplicate"}: + commits = 2 + elif scenario == "metadata-head-mismatch": + metadata_head = new_head + emit({"commits": commits, "head": {"sha": metadata_head}, "user": {"login": "author"}}) + elif "branches/master/protection/required_status_checks" in joined: + contexts = [ + "Frontend build", "Rust format", "GitGuardian Security Checks", + "Dependency security", "Required checks"] + if scenario == "protection-missing-gitguardian": + contexts = [context for context in contexts if context != "GitGuardian Security Checks"] + if scenario == "required-context-output-bound": + contexts += ["control-" + str(i) + "-" + "z" * 10000 for i in range(50)] + if scenario == "required-context-unexpected": + contexts += ["Unexpected required context"] + emit({"strict": scenario != "protection-nonstrict", "contexts": contexts, "checks": []}) + elif "branches/master" in joined: + emit(base) + elif "/pulls/321/reviews" in joined: + if scenario in {"short-review", "summary-only", "manual-summary"}: + emit([[]]) + else: + records = [{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, "state": "COMMENTED", "commit_id": head}] + if security_case and scenario != "security-review-missing": + record = {"user": {"login": "reviewer", "type": "User"}, "author_association": "COLLABORATOR", "state": "COMMENTED", "commit_id": head, "body": f"Security review: {head}\nResult: accepted\nReviewed credential handling and error redaction."} + if scenario == "security-review-stale": record["commit_id"] = new_head + if scenario == "security-review-author": record["user"]["login"] = "author" + if scenario == "security-review-unrelated": record["body"] = "Looks good" + if scenario == "security-review-outsider": record["author_association"] = "NONE" + if scenario == "security-review-dismissed": record["state"] = "DISMISSED" + records.append(record) + emit([records]) + elif "/issues/321/comments" in joined: + if scenario == "short-review": + summary = f"codex-pull-request-review-summary\n| 📝 | ✅ **Completed** | `{head[:7]}` |" + emit([[{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, "body": summary}]]) + elif scenario == "summary-failed": + summary = f"codex-pull-request-review-summary\n| 📝 | ❌ **Failed** | `{head[:7]}` |" + emit([[{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, "body": summary}]]) + elif scenario in {"summary-only", "manual-summary"}: + summary = f"codex-pull-request-review-summary\n| 📝 | ✅ **Completed** | `{head[:7]}` |" + emit([[{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, "body": summary}]]) + else: + summary = f"codex-pull-request-review-summary\n| 📝 | ✅ **Completed** | `{head[:7]}` |" + emit([[{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, "body": summary}]]) + elif "/pulls/321/files" in joined: + if security_case: + emit([[{"filename": "src-tauri/src/dsc.rs", "status": "modified", "additions": 1, "deletions": 0}]]) + elif sync_case: + emit([[{"filename": "docs/retired.rs", "previous_filename": "src-tauri/src/sync.rs", "status": "renamed", "additions": 0, "deletions": 0}]]) + elif scenario == "files-empty": + emit([[]]) + elif scenario in {"formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized"}: + emit([[{"filename": "docs/contact.md", "status": "added", "additions": 2 if scenario == "unicode-phone-two-lines" else 1, "deletions": 0}]]) + elif scenario in {"workflow-notes-missing", "workflow-notes-present", "workflow-placeholders"}: + emit([[{"filename": ".github/workflows/ci.yml", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"workflow-delete", "workflow-delete-notes"}: + emit([[{"filename": ".github/workflows/ci.yml", "status": "removed", "additions": 0, "deletions": 1}]]) + elif scenario in {"workflow-rename-out", "workflow-rename-out-notes"}: + emit([[{"filename": "docs/retired-ci.yml", "previous_filename": ".github/workflows/ci.yml", "status": "renamed", "additions": 0, "deletions": 0}]]) + elif scenario == "renamed-previous-missing": + emit([[{"filename": "docs/retired-ci.yml", "status": "renamed", "additions": 0, "deletions": 0}]]) + elif scenario == "renamed-previous-null": + emit([[{"filename": "docs/retired-ci.yml", "previous_filename": None, "status": "renamed", "additions": 0, "deletions": 0}]]) + elif scenario == "renamed-previous-false": + emit([[{"filename": "docs/retired-ci.yml", "previous_filename": False, "status": "renamed", "additions": 0, "deletions": 0}]]) + elif scenario == "path-id": + path_id = "ABCDE" + "1234" + "F" + emit([[{"filename": f"docs/{path_id}.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario in {"security-notes-missing", "security-notes-present", "security-none", "security-pending"}: + emit([[{"filename": "src-tauri/src/tally/runtime.rs", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "security-rename-out": + emit([[{"filename": "src/runtime.rs", "previous_filename": "src-tauri/src/tally/runtime.rs", "status": "renamed", "additions": 0, "deletions": 0}]]) + elif scenario in {"security-crate", "security-agent-import", "security-dsc"}: + paths = {"security-crate": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", "security-agent-import": "src-tauri/src/agent_import.rs", "security-dsc": "src-tauri/src/dsc.rs"} + emit([[{"filename": paths[scenario], "status": "removed", "additions": 0, "deletions": 1}]]) + elif scenario.startswith("home-"): + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario == "binary-delete": + emit([[{"filename": "docs/old.png", "status": "removed", "additions": 0, "deletions": 0}]]) + elif scenario == "metadata-only": + emit([[{"filename": "docs/example.md", "status": "modified", "additions": 0, "deletions": 0}]]) + elif scenario == "metadata-private": + emit([[{"filename": "docs/" + "/" + "Users" + "/tester/" + "x" * 300, "status": "modified", "additions": 0, "deletions": 0}]]) + elif scenario == "metadata-incomplete": + emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "hunk-header-phone": + emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "hunk-header-literals": + emit([[{"filename": "docs/example.md", "status": "modified", "additions": 2, "deletions": 0}]]) + elif scenario == "hunk-binary-literal": + emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"all-a-pan", "masked-pan", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier"}: + emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "quoted-path": + emit([[{"filename": "docs/café.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario in {"crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path"}: + status = "renamed" if scenario == "ambiguous-rename-path" else "added" + record = {"filename": "docs/a b/example.md" if scenario != "crlf-diff" else "docs/example.md", "status": status, "additions": 0 if status == "renamed" else 1, "deletions": 0} + if status == "renamed": record["previous_filename"] = "docs/a b/example.md" + emit([[record]]) + elif scenario == "gitlink": + emit([[{"filename": "vendor/module", "status": "modified", "additions": 1, "deletions": 1}]]) + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "implementation-p4-powershell", "implementation-p4-sql", "p4-placeholders"}: + suffix = {"implementation-p4-shell": "sh", "implementation-p4-powershell": "ps1", "implementation-p4-sql": "sql"}.get(scenario, "py") + emit([[{"filename": f"scripts/example.{suffix}", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: + emit([[{"filename": "src-tauri/src/local_files/paths.rs", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: + emit([[{"filename": "src-tauri/migrations/001.sql", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "control-path": + emit([[{"filename": "docs/unsafe\x1b.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario == "malformed-files": + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}], [{"filename": 3, "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "missing-file-status": + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}], [{"filename": "docs/second.md", "additions": 1, "deletions": 0}]]) + elif scenario == "surface-unpins": + emit([[{"filename": "src/example.rs", "status": "modified", "additions": 1, "deletions": 0}], + [{"filename": "docs/tally/compatibility/compatibility-surface.json", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario == "files-count-mismatch": + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}]]) + else: + additions = 2 if scenario == "diff-truncated-payload" else 1 + emit([[{"filename": "docs/example.md", "status": "added", "additions": additions, "deletions": 0}], [{"filename": "docs/second.md", "status": "modified", "additions": 1, "deletions": 0}]]) + elif "/contents/" in joined: + if "review-checklist.md" in joined: + checklist = "# Review checklist\n" + "\n" * 8 + "- [ ] Errors are actionable without exposing sensitive values.\n" + emit({"encoding": "base64", "content": base64.b64encode(checklist.encode()).decode()}) + elif scenario in {"surface-fail", "draft-surface-fail"}: + fail("controlled surface read failure") + elif scenario == "surface-malformed": + emit({"content": "not-base64"}) + else: + digest = "a" * 64 + if scenario == "surface-unpins" and f"ref={head}" not in joined: + surface_files = [ + {"path": "src/example.rs", "sha256": digest}, + {"path": "docs/tally/compatibility/compatibility-surface.json", "sha256": digest}, + ] + else: + surface_files = [{"path": "src/example.rs", "sha256": digest}] + surface = {"schema_version": 1, "manifest_sha256": digest, + "files": surface_files} + if scenario == "surface-schema-malformed": + surface = {"files": [{"path": "src/example.rs"}]} + emit({"encoding": "base64", "content": base64.b64encode(json.dumps(surface).encode()).decode()}) + else: + fail("unknown API fixture") +else: + fail("unknown command fixture") +''' + + +class MergeGateControls(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory(prefix="merge-gate-controls-") + cls.bin = Path(cls.tmp.name) + gh = cls.bin / "gh" + gh.write_text(FAKE_GH) + gh.chmod(0o755) + jq = cls.bin / "jq" + jq.write_text("""#!/usr/bin/env python3 +import os, sys +if os.environ.get("GATE_SCENARIO") == "context-report-jq-failure" and "--rawfile" in sys.argv and "contexts" in sys.argv: + raise SystemExit(1) +os.execv(os.environ["GATE_REAL_JQ"], [os.environ["GATE_REAL_JQ"], *sys.argv[1:]]) +""") + jq.chmod(0o755) + cls.real_jq = shutil.which("jq") + if not cls.real_jq: + raise RuntimeError("jq is required for merge-gate controls") + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_gate(self, scenario="pass", extra_args=()): + env = os.environ.copy() + env["PATH"] = f"{self.bin}:{env['PATH']}" + env["GATE_SCENARIO"] = scenario + env["GATE_REAL_JQ"] = self.real_jq + counter = self.bin / f"{scenario}-counter-{os.getpid()}" + counter.write_text("0") + env["GATE_COUNTER"] = str(counter) + return subprocess.run( + [str(SCRIPT), "321", "--repo", "lamemustafa/bridge", *extra_args], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def assert_blocked(self, scenario, phrase): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn(phrase, result.stdout) + self.assertNotIn("MAY MERGE", result.stdout) + + def assert_indeterminate(self, scenario, phrase): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn(phrase, result.stdout) + self.assertNotIn("MAY MERGE", result.stdout) + + def test_server_full_sha_and_paginated_pages_can_pass(self): + result = self.run_gate() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("provider review records the full current head", result.stdout) + self.assertIn("MAY MERGE", result.stdout) + self.assertIn("--match-head-commit 0123456789abcdef0123456789abcdef01234567", result.stdout) + + def test_optional_skipped_check_does_not_block(self): + result = self.run_gate() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("optional check(s) are skipped", result.stdout) + + def test_required_skipped_check_blocks(self): + self.assert_blocked("required-skip", "required check 'Required checks' is not passing") + + def test_incomplete_check_run_is_indeterminate(self): + self.assert_indeterminate("check-run-incomplete", "head-bound check-run evidence") + + def test_empty_pending_legacy_status_does_not_block(self): + result = self.run_gate("status-empty-pending") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_missing_required_context_blocks(self): + self.assert_blocked("missing-required", "required check 'Rust format' was not reported") + + def test_unexpected_required_context_blocks(self): + self.assert_blocked("required-context-unexpected", "undocumented required check context") + + def test_cancelled_check_blocks(self): + self.assert_blocked("cancel-check", "failing, cancelled, or pending") + + def test_surface_transport_failure_is_indeterminate(self): + self.assert_indeterminate("surface-fail", "could not read and validate compatibility surface") + + def test_silent_checks_response_is_indeterminate(self): + self.assert_indeterminate("checks-silent", "checks query returned no JSON") + + def test_summary_only_short_sha_is_indeterminate(self): + self.assert_indeterminate("short-review", "independent-review-sha") + + def test_manual_full_sha_attestation_accepts_zero_finding_summary(self): + result = self.run_gate("manual-summary", ("--independent-review-sha", HEAD)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("manual independent review attestation", result.stdout) + + def test_provider_review_requires_completed_summary(self): + self.assert_blocked("summary-failed", "review run for 0123456 is not completed") + + def test_blocked_merge_state_cannot_pass(self): + self.assert_blocked("blocked-state", "merge state BLOCKED") + + def test_unrecognised_final_merge_state_is_indeterminate(self): + self.assert_indeterminate("final-unrecognized", "unrecognised value") + + def test_short_thread_page_is_indeterminate(self): + self.assert_indeterminate("threads-short", "returned 1 of 101 nodes") + + def test_unresolved_second_thread_page_blocks(self): + self.assert_blocked("threads-unresolved-second", "1 of 101 review threads unresolved") + + def test_malformed_thread_pagination_is_indeterminate(self): + self.assert_indeterminate("threads-malformed-pagination", "no advancing cursor") + + def test_thread_total_drift_is_indeterminate(self): + self.assert_indeterminate("threads-total-drift", "totalCount changed") + + def test_head_change_is_blocked(self): + self.assert_blocked("head-moves", "PR head moved during preflight") + + def test_formatted_phone_is_scanned(self): + self.assert_blocked("formatted-phone", "privacy scan found") + + def test_hidden_metadata_identifier_is_still_scanned(self): + self.assert_blocked("hidden-comment-identifier", "privacy scan found") + + def test_unterminated_comment_cannot_supply_validation(self): + self.assert_blocked("unterminated-html-comment", "actual test or reproduction command") + + def test_multiline_comment_cannot_supply_functional_summary(self): + self.assert_blocked("policy-multiline-comment", "non-empty functional summary") + + def test_raw_html_comment_drift_blocks_revalidation(self): + self.assert_blocked("body-comment-drift", "description changed during preflight") + + def test_repeated_digit_phone_is_scanned(self): + self.assert_blocked("repeated-phone", "privacy scan found") + + def test_grouped_formatted_phone_is_scanned(self): + self.assert_blocked("formatted-phone-grouped", "privacy scan found") + + def test_separated_indian_mobile_styles_are_scanned(self): + for scenario in ("phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "unicode-phone"): + with self.subTest(scenario=scenario): + self.assert_blocked(scenario, "privacy scan found") + result = self.run_gate("unicode-phone-two-lines") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_header_shaped_added_payload_is_still_scanned(self): + self.assert_blocked("hunk-header-phone", "privacy scan found") + + def test_header_shaped_added_literals_count_as_payload(self): + result = self.run_gate("hunk-header-literals") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_binary_shaped_added_literal_is_not_binary_metadata(self): + result = self.run_gate("hunk-binary-literal") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_unrelated_separator_groups_are_not_fused_into_a_phone(self): + result = self.run_gate("separated-dates") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_new_year_date_ranges_are_not_fused_into_a_phone(self): + result = self.run_gate("separated-dates-new-year") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_year_month_date_ranges_are_not_fused_into_a_phone(self): + result = self.run_gate("separated-dates-year-month") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_adr_identifier_with_underscores_remains_clean(self): + result = self.run_gate("adr-identifier") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_destination_path_is_scanned(self): + self.assert_blocked("path-id", "privacy scan found") + + def test_binary_deletion_is_not_treated_as_added_content(self): + result = self.run_gate("binary-delete") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_malformed_surface_is_indeterminate(self): + self.assert_indeterminate("surface-malformed", "could not read and validate compatibility surface") + + def test_surface_with_no_v1_manifest_schema_is_indeterminate(self): + self.assert_indeterminate("surface-schema-malformed", "could not read and validate compatibility surface") + + def test_removed_base_pin_requires_human_hold(self): + self.assert_indeterminate("surface-unpins", "base-pinned path(s) are absent") + + def test_compare_lineage_mismatch_is_indeterminate(self): + self.assert_indeterminate("lineage-mismatch", "base/head compare did not prove") + + def test_check_run_wrong_head_is_indeterminate(self): + self.assert_indeterminate("check-run-wrong-head", "head-bound check-run evidence") + + def test_check_run_count_mismatch_is_indeterminate(self): + self.assert_indeterminate("check-run-count-mismatch", "head-bound check-run evidence") + + def test_base_oid_mismatch_is_indeterminate(self): + self.assert_indeterminate("base-oid-mismatch", "PR base OID") + + def test_malformed_commit_status_is_indeterminate(self): + self.assert_indeterminate("status-malformed", "head-bound commit-status evidence") + + def test_malformed_changed_file_is_indeterminate(self): + self.assert_indeterminate("malformed-files", "could not read the complete changed-file set") + + def test_missing_changed_file_status_is_indeterminate(self): + self.assert_indeterminate("missing-file-status", "could not read the complete changed-file set") + + def test_changed_file_count_mismatch_is_indeterminate(self): + self.assert_indeterminate("files-count-mismatch", "changed-file response has") + + def test_empty_file_array_is_not_one_filename(self): + self.assert_indeterminate("files-empty", "contained no filenames") + + def test_fractional_thread_count_is_indeterminate(self): + self.assert_indeterminate("threads-fractional", "could not read review threads") + + def test_empty_page_cannot_claim_more_threads(self): + self.assert_indeterminate("threads-empty-more", "no nodes while claiming another page") + + def test_template_checklist_permalink_on_continuation_passes(self): + result = self.run_gate("checklist-template-continuation") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("description links a completed review-checklist item", result.stdout) + + def test_checklist_permalink_must_bind_the_full_current_head(self): + self.assert_blocked("checklist-stale-ref", "same-repository line-specific") + self.assert_blocked("checklist-anchor-suffix", "same-repository line-specific") + + def test_foreign_checklist_link_blocks(self): + self.assert_blocked("checklist-foreign", "same-repository line-specific") + + def test_unlinked_checklist_text_blocks(self): + self.assert_blocked("checklist-unlinked", "same-repository line-specific") + + def test_omitted_nonremoved_diff_file_is_indeterminate(self): + self.assert_indeterminate("diff-omits-file", "privacy diff coverage failed") + + def test_truncated_textual_diff_payload_is_indeterminate(self): + self.assert_indeterminate("diff-truncated-payload", "privacy diff coverage failed") + + def test_metadata_only_zero_line_diff_can_pass(self): + result = self.run_gate("metadata-only") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("metadata-only diff section", result.stdout) + + def test_metadata_only_examples_are_redacted_and_bounded(self): + result = self.run_gate("metadata-private") + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("metadata-only diff section", result.stdout) + self.assertNotIn("privacy diff coverage failed", result.stdout) + self.assertNotIn("tester", result.stdout) + examples = next(line for line in result.stdout.splitlines() if "metadata-only diff section" in line) + self.assertLess(len(examples), 260) + self.assertNotIn("x" * 161, examples) + + def test_metadata_only_diff_requires_rest_zero_totals(self): + self.assert_indeterminate("metadata-incomplete", "metadata-only 'docs/example.md' conflicts") + + def test_pr_selector_must_be_numeric(self): + env = os.environ.copy() + env["PATH"] = f"{self.bin}:{env['PATH']}" + result = subprocess.run( + [str(SCRIPT), "321;echo unsafe", "--repo", "lamemustafa/bridge"], + cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("PR selector must be numeric", result.stderr) + + def test_repo_identity_must_be_safe(self): + env = os.environ.copy() + env["PATH"] = f"{self.bin}:{env['PATH']}" + result = subprocess.run( + [str(SCRIPT), "321", "--repo", "lamemustafa/bridge;echo unsafe"], + cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("--repo must be OWNER/NAME", result.stderr) + + def test_other_repository_policy_is_not_assumed(self): + env = os.environ.copy() + env["PATH"] = f"{self.bin}:{env['PATH']}" + result = subprocess.run( + [str(SCRIPT), "321", "--repo", "example/other"], + cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("unsupported repository", result.stderr) + + def test_missing_functional_summary_blocks(self): + self.assert_blocked("missing-functional-summary", "functional summary") + + def test_missing_test_summary_blocks(self): + self.assert_blocked("missing-test-summary", "test or reproduction command") + + def test_final_changed_body_revalidates_test_evidence(self): + self.assert_blocked("body-loses-evidence", "description changed and no longer carries test or reproduction evidence") + + def test_final_changed_body_revalidates_functional_summary(self): + self.assert_blocked("body-loses-functional", "description changed and no longer carries a non-empty functional summary") + + def test_draft_merge_state_blocks(self): + self.assert_blocked("draft-state", "merge state DRAFT") + + def test_documented_required_context_cannot_be_omitted(self): + self.assert_blocked("protection-missing-gitguardian", "branch protection omits 1 documented") + + def test_failing_combined_commit_status_blocks(self): + self.assert_indeterminate("status-failed-combined", "head-bound commit-status evidence") + + def test_failing_individual_commit_status_blocks(self): + self.assert_indeterminate("status-failed-context", "head-bound commit-status evidence") + + def test_duplicate_thread_ids_are_indeterminate(self): + self.assert_indeterminate("threads-duplicate-id", "pagination repeated thread IDs") + + def test_checklist_anchor_must_reference_an_existing_line(self): + self.assert_blocked("checklist-missing-anchor", "review-checklist link to an existing line") + + def test_template_functional_prompt_does_not_count_as_summary(self): + self.assert_blocked("template-functional-prompt", "non-empty functional summary") + + def test_template_validation_prompts_do_not_count_as_evidence(self): + self.assert_blocked("template-validation-prompt", "test or reproduction command") + + def test_pr_title_identifier_is_scanned(self): + self.assert_blocked("metadata-title-id", "privacy scan found") + + def test_pr_commit_metadata_identifier_is_scanned(self): + self.assert_blocked("metadata-commit-id", "privacy scan found") + + def test_standard_commit_identity_fields_are_validated_and_scanned(self): + self.assert_blocked("metadata-author-id", "privacy scan found") + result = self.run_gate("metadata-unlinked-identities") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_capped_commit_metadata_is_indeterminate(self): + self.assert_indeterminate("metadata-capped", "complete head-bound PR commit metadata") + + def test_truncated_commit_metadata_is_indeterminate(self): + self.assert_indeterminate("metadata-truncated", "complete head-bound PR commit metadata") + + def test_duplicate_commit_metadata_is_indeterminate(self): + self.assert_indeterminate("metadata-duplicate", "complete head-bound PR commit metadata") + + def test_commit_metadata_head_mismatch_is_indeterminate(self): + self.assert_indeterminate("metadata-head-mismatch", "complete head-bound PR commit metadata") + + def test_all_a_pan_is_not_exempted_as_a_placeholder(self): + self.assert_blocked("all-a-pan", "privacy scan found") + + def test_explicit_masked_pan_remains_a_placeholder(self): + result = self.run_gate("masked-pan") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_quoted_git_destination_path_is_covered(self): + result = self.run_gate("quoted-path") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_crlf_and_ambiguous_unquoted_diffs_preserve_coverage(self): + for scenario in ("crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path"): + with self.subTest(scenario=scenario): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_gitlink_requires_explicit_provenance_license_notice_review(self): + self.assert_indeterminate("gitlink", "gitlink change(s) require explicit provenance, license, and NOTICE review") + + def test_control_character_in_destination_path_is_indeterminate(self): + self.assert_indeterminate("control-path", "could not read the complete changed-file set") + + def test_populated_sha_without_command_is_not_validation_evidence(self): + self.assert_blocked("template-validation-sha-only", "test or reproduction command") + + def test_validation_heading_without_command_is_not_evidence(self): + self.assert_blocked("template-validation-empty", "actual test or reproduction command") + + def test_protection_requires_strict_status_checks(self): + self.assert_indeterminate("protection-nonstrict", "required status-check response was malformed") + + def test_duplicate_check_run_ids_are_indeterminate(self): + self.assert_indeterminate("check-run-duplicate-id", "head-bound check-run evidence") + + def test_grouped_12_and_16_digit_identifiers_are_scanned(self): + for scenario in ("grouped-identifier-12", "grouped-identifier-16"): + with self.subTest(scenario=scenario): + self.assert_blocked(scenario, "privacy scan found") + + def test_workflow_change_requires_rollback_and_migration_notes(self): + self.assert_blocked("workflow-notes-missing", "workflow change lacks non-empty rollback notes") + self.assert_blocked("workflow-placeholders", "workflow change lacks non-empty rollback notes") + + def test_workflow_change_with_required_notes_can_pass(self): + result = self.run_gate("workflow-notes-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_workflow_delete_and_rename_out_require_notes(self): + for scenario in ("workflow-delete", "workflow-rename-out"): + with self.subTest(scenario=scenario): + self.assert_blocked(scenario, "workflow change lacks non-empty rollback notes") + + def test_workflow_delete_and_rename_out_with_notes_can_pass(self): + for scenario in ("workflow-delete-notes", "workflow-rename-out-notes"): + with self.subTest(scenario=scenario): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_renamed_file_requires_a_string_previous_filename(self): + for scenario in ("renamed-previous-missing", "renamed-previous-null", "renamed-previous-false"): + with self.subTest(scenario=scenario): + self.assert_indeterminate(scenario, "could not read the complete changed-file set") + + def test_sensitive_paths_require_security_impact_notes_including_renames(self): + for scenario in ("security-notes-missing", "security-rename-out", "security-crate", "security-agent-import", "security-dsc"): + with self.subTest(scenario=scenario): + self.assert_blocked(scenario, "security-impact notes") + result = self.run_gate("security-notes-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = self.run_gate("security-none") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_blocked("security-pending", "security-impact notes") + + def test_sensitive_paths_without_security_attestation_are_indeterminate(self): + self.assert_indeterminate("security-review-missing", "security-focused reviewer comment") + + def test_refreshed_failed_or_required_skipped_runs_block(self): + for scenario in ("check-run-failed", "check-run-required-skip"): + with self.subTest(scenario=scenario): + self.assert_blocked(scenario, "refreshed check run(s)") + result = self.run_gate("check-run-optional-skip") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_nonempty_pending_legacy_status_is_indeterminate(self): + self.assert_indeterminate("status-nonempty-pending", "commit-status evidence") + + def test_truncated_legacy_status_pages_are_indeterminate(self): + self.assert_indeterminate("status-truncated", "complete head-bound commit-status evidence") + + def test_complete_combined_status_pages_bind_head_and_contexts(self): + result = self.run_gate("status-two-page") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for scenario in ("status-truncated", "status-duplicate", "status-mixed-head"): + with self.subTest(scenario=scenario): + self.assert_indeterminate(scenario, "complete head-bound commit-status evidence") + + def test_validation_commands_must_be_concrete_and_in_validation(self): + for scenario in ("validation-command-outside", "validation-tool-prose", "validation-placeholder-command", "validation-html-comment"): + with self.subTest(scenario=scenario): + self.assert_blocked(scenario, "actual test or reproduction command") + + def test_checklist_heading_is_not_a_completed_item(self): + self.assert_blocked("checklist-heading", "review-checklist link") + + def test_context_report_jq_failure_is_indeterminate_without_fabricated_count(self): + result = self.run_gate("context-report-jq-failure") + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("could not compute bounded required-check diagnostics", result.stdout) + self.assertNotIn("1 of 0 required check contexts", result.stdout) + self.assertNotIn("all 0 required check contexts passed", result.stdout) + + def test_required_context_diagnostics_are_bounded_for_failures(self): + result = self.run_gate("required-context-output-bound") + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("50 of 55 required check contexts", result.stdout) + self.assertLess(len(result.stdout.encode()), 6000) + self.assertNotIn("z" * 100, result.stdout) + + def test_security_review_is_focused_independent_and_current(self): + for scenario in ("security-review-stale", "security-review-author", "security-review-unrelated", "security-review-outsider", "security-review-dismissed"): + with self.subTest(scenario=scenario): + self.assert_indeterminate(scenario, "security-focused reviewer comment") + result = self.run_gate("security-review-valid") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_sync_rename_out_requires_migration_notes(self): + self.assert_blocked("sync-migration-missing", "migration compatibility") + result = self.run_gate("sync-migration-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_implementation_additions_need_all_three_p4_answers(self): + self.assert_blocked("implementation-p4-missing", "all three substantive P4") + self.assert_blocked("implementation-p4-shell", "all three substantive P4") + self.assert_blocked("implementation-p4-powershell", "all three substantive P4") + self.assert_blocked("implementation-p4-sql", "all three substantive P4") + self.assert_blocked("p4-placeholders", "all three substantive P4") + result = self.run_gate("implementation-p4-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_platform_sensitive_paths_need_substantive_both_host_evidence(self): + self.assert_blocked("platform-evidence-missing", "substantive Windows validation") + result = self.run_gate("platform-evidence-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = self.run_gate("platform-checkbox-evidence") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_blocked("platform-checkbox-comment", "substantive Windows validation") + self.assert_blocked("platform-unaffected-bare", "substantive Windows validation") + result = self.run_gate("platform-unaffected-rationale") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_database_migration_paths_need_rollback_notes(self): + self.assert_blocked("migration-rollback-missing", "database migration path lacks non-empty rollback notes") + result = self.run_gate("migration-rollback-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_wrapped_canonical_migration_template_field_is_recognized(self): + result = self.run_gate("migration-template-wrapped") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_blocked("migration-template-other-field", "database migration path lacks non-empty rollback notes") + + def test_developer_home_path_shapes_are_scanned_without_echoing_values(self): + for scenario in ("home-macos", "home-unix", "home-windows", "home-macos-root", "home-unix-root", "home-windows-forward", "home-windows-escaped"): + with self.subTest(scenario=scenario): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("developer-home path shape", result.stdout) + self.assertNotIn("tester", result.stdout) + result = self.run_gate("home-regex-source") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_diff_parser_uses_python38_compatible_prefix_removal(self): + import importlib.util + parser = ROOT / "scripts" / "merge_gate_diff.py" + spec = importlib.util.spec_from_file_location("merge_gate_diff", parser) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + class NoRemovePrefix(str): + def removeprefix(self, _prefix): + raise AssertionError("Python 3.8 lacks str.removeprefix") + self.assertEqual(module.diff_destination(NoRemovePrefix("diff --git a/x b/y")), "y") + self.assertEqual(module.textual_destination(NoRemovePrefix("+++ b/y")), "y") + + def test_diff_parser_accepts_git_generated_mixed_quote_rename_headers(self): + import importlib.util + parser = ROOT / "scripts" / "merge_gate_diff.py" + spec = importlib.util.spec_from_file_location("merge_gate_diff_mixed", parser) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + unicode_name = '"b/\\351\\233\\252.txt"' + self.assertEqual(module.diff_destination('diff --git "a/\\351\\233\\252.txt" b/plain.txt'), "plain.txt") + self.assertEqual(module.diff_destination('diff --git a/plain.txt ' + unicode_name), "雪.txt") + self.assertEqual(module.diff_destination("diff --git a/foo b/bar b/foo b/bar"), "foo b/bar") + + def test_definite_blocker_wins_over_indeterminate_evidence(self): + self.assert_blocked("draft-surface-fail", "merge state DRAFT") + result = self.run_gate("draft-surface-fail") + self.assertNotIn("INDETERMINATE — do not merge", result.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/merge_gate_diff.py b/scripts/merge_gate_diff.py new file mode 100755 index 000000000..165e56bb6 --- /dev/null +++ b/scripts/merge_gate_diff.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Parse the textual portion of a GitHub pull-request diff for merge-gate.""" +from __future__ import annotations + +import json +import sys + + +def remove_prefix(value: str, prefix: str) -> str: + """Python 3.8-compatible equivalent of str.removeprefix.""" + return value[len(prefix):] if value.startswith(prefix) else value + + +def decode_quoted_path(value: str) -> str: + if not (value.startswith('"') and value.endswith('"')): + raise ValueError("expected quoted path") + body = value[1:-1] + raw = bytearray() + index = 0 + while index < len(body): + char = body[index] + if char != "\\": + raw.extend(char.encode("utf-8")) + index += 1 + continue + index += 1 + if index == len(body): + raise ValueError("unterminated escape") + escaped = body[index] + if escaped in '"\\': + raw.append(ord(escaped)) + index += 1 + elif escaped in "abfnrtv": + raw.append({"a": 7, "b": 8, "f": 12, "n": 10, "r": 13, "t": 9, "v": 11}[escaped]) + index += 1 + elif escaped in "01234567": + octal = body[index:index + 3] + if len(octal) != 3 or any(char not in "01234567" for char in octal): + raise ValueError("invalid octal escape") + raw.append(int(octal, 8)) + index += 3 + else: + raise ValueError("unsupported escape") + return raw.decode("utf-8") + + +def quoted_token(value: str, start: int) -> tuple[str, int]: + if value[start] != '"': + raise ValueError("expected quote") + index = start + 1 + escaped = False + while index < len(value): + char = value[index] + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + return value[start:index + 1], index + 1 + index += 1 + raise ValueError("unterminated quoted token") + + +def diff_destination(line: str) -> str | None: + value = remove_prefix(line, "diff --git ") + if value.startswith('"'): + _source, index = quoted_token(value, 0) + if index >= len(value) or value[index] != " ": + raise ValueError("missing destination") + destination = value[index + 1:] + path = decode_quoted_path(destination) if destination.startswith('"') else destination + if not path.startswith("b/"): + raise ValueError("destination does not start b/") + return path[2:] + # Git quotes each path independently. A plain source with a quoted + # destination is therefore valid (and occurs for plain-to-Unicode + # renames); the quote itself cannot occur in an unquoted token. + quoted_destination = value.find(' "b/') + if quoted_destination >= 0: + source = value[:quoted_destination] + destination = value[quoted_destination + 1:] + if not source.startswith("a/"): + raise ValueError("invalid unquoted source") + path = decode_quoted_path(destination) + if not path.startswith("b/"): + raise ValueError("destination does not start b/") + return path[2:] + # An unquoted header has no escaping grammar. Splitting on the first + # `` b/`` silently misparses a legal-looking filename containing that + # sequence. Defer an ambiguous header to the independently parsed +++ + # destination; that destination is subsequently reconciled to the REST + # changed-file inventory. Metadata-only ambiguous records remain + # indeterminate because they have no unambiguous textual identity. + parts = value.split(" b/") + if len(parts) != 2: + # Pure mode/deletion records may have neither +++ nor rename-to. A + # same-path header can still be proven when exactly one candidate + # delimiter leaves identical a/ and b/ paths. Do not guess when the + # filename makes that proof ambiguous. + matches: list[str] = [] + start = 0 + while True: + index = value.find(" b/", start) + if index < 0: + break + source = value[:index] + destination = value[index + 1:] + if source.startswith("a/") and destination.startswith("b/") and source[2:] == destination[2:]: + matches.append(destination[2:]) + start = index + 1 + return matches[0] if len(matches) == 1 else None + source, destination = parts + if not source.startswith("a/") or not destination: + raise ValueError("invalid unquoted header") + return destination + + +def textual_destination(line: str) -> str | None: + value = remove_prefix(line, "+++ ") + if value == "/dev/null": + return None + if value.startswith('"'): + path = decode_quoted_path(value) + else: + path = value + if not path.startswith("b/"): + raise ValueError("textual destination does not start b/") + return path[2:] + + +def parse(lines: list[str]) -> dict[str, object]: + records: list[dict[str, object]] = [] + added_payload: list[str] = [] + record: dict[str, object] | None = None + + def emit() -> None: + if record is not None: + records.append(record.copy()) + + for raw_line in lines: + # A diff is delimited by LF. CR from CRLF or in an added payload is + # content for scanning/counting, but must not prevent recognising a + # protocol header. + line = raw_line[:-1] if raw_line.endswith("\r") else raw_line + if line.startswith("diff --git "): + emit() + record = { + "destination": diff_destination(line), + "textual_destination": None, + "rename_destination": None, + "added": 0, + "deleted": 0, + "binary": False, + "gitlink": False, + "in_hunk": False, + } + continue + if record is None: + continue + if not record["in_hunk"] and line.startswith("+++ "): + record["textual_destination"] = textual_destination(line) + continue + if not record["in_hunk"] and line.startswith("rename to "): + destination = remove_prefix(line, "rename to ") + record["rename_destination"] = decode_quoted_path(destination) if destination.startswith('"') else destination + continue + if not record["in_hunk"] and line in {"GIT binary patch"} or ( + not record["in_hunk"] and line.startswith("Binary files ") and line.endswith(" differ") + ): + record["binary"] = True + continue + if not record["in_hunk"] and line in { + "old mode 160000", "new mode 160000", "new file mode 160000", + "deleted file mode 160000", + }: + record["gitlink"] = True + continue + if line.startswith("@@ "): + record["in_hunk"] = True + continue + if record["in_hunk"] and raw_line.startswith("+"): + record["added"] = int(record["added"]) + 1 + added_payload.append(raw_line[1:]) + elif record["in_hunk"] and raw_line.startswith("-"): + record["deleted"] = int(record["deleted"]) + 1 + emit() + for record in records: + record.pop("in_hunk") + if record["destination"] is None: + if record["textual_destination"] is None and record["rename_destination"] is None: + raise ValueError("ambiguous header without textual destination") + record["destination"] = record["textual_destination"] or record["rename_destination"] + record.pop("rename_destination") + return {"records": records, "added_payload": added_payload} + + +if __name__ == "__main__": + try: + raw = sys.stdin.buffer.read().decode("utf-8") + # Split only on the protocol's LF delimiter. Do not let Python's + # universal-newline mode erase CR or Unicode line-separator payload. + print(json.dumps(parse(raw.split("\n")))) + except (UnicodeError, ValueError) as error: + print(f"merge_gate_diff_error:{error}", file=sys.stderr) + raise SystemExit(2)