From abe9dbb5c1a34f72f3a5f7a1f8301a13900f9b85 Mon Sep 17 00:00:00 2001 From: be-coder-08 Date: Wed, 5 Aug 2026 17:31:54 -0500 Subject: [PATCH] test(pr-merge): register message field contract --- docs/scratchpads/pr-merge-message-field.md | 38 +++ infra/fleet/tools/git/pr-merge.sh | 247 +++++++++++++++++ .../tools/git/test-pr-merge-message-field.sh | 248 ++++++++++++++++++ 3 files changed, 533 insertions(+) create mode 100644 docs/scratchpads/pr-merge-message-field.md create mode 100755 infra/fleet/tools/git/pr-merge.sh create mode 100755 infra/fleet/tools/git/test-pr-merge-message-field.sh diff --git a/docs/scratchpads/pr-merge-message-field.md b/docs/scratchpads/pr-merge-message-field.md new file mode 100644 index 00000000..210db0b8 --- /dev/null +++ b/docs/scratchpads/pr-merge-message-field.md @@ -0,0 +1,38 @@ +# PR merge squash message field + +- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md` +- **Owner:** `be-coder-08` +- **Branch:** `fix/pr-merge-message-field` +- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a` +- **Estate:** HOMELAB tooling shared by HOMELAB and USC + +## Objective + +Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior. + +## Binding requirements + +1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style. +2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer. +3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal. +4. The BLOCK arm must be observed firing; a normal single-author API payload remains exactly `{ "Do": "squash" }`. +5. Every provider mutation is read back from the provider; no real PR is merged during tests. + +## Derived interface decisions + +- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible. +- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role. +- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom. +- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email. + +## Plan + +1. Version the currently deployed wrapper byte-for-byte under `infra/fleet/tools/git/pr-merge.sh`. +2. Pre-register verified, null-login BLOCK, unchanged single-author, and hardcoded-squash tests; observe RED before implementation. +3. Implement authenticated commit enumeration, construction-only trailers, message fields on REST, and force REST when trailers are requested. +4. Copy the exact final versioned bytes to the deployed wrapper; verify hashes match. +5. Run focused and baseline checks, static/security review, identity-bound commit, queue guard plus direct Woodpecker terminal enumeration, push, self-post PR, and provider poster read-back. Stop at push/PR; do not merge. + +## Evidence + +- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`. diff --git a/infra/fleet/tools/git/pr-merge.sh b/infra/fleet/tools/git/pr-merge.sh new file mode 100755 index 00000000..222260c6 --- /dev/null +++ b/infra/fleet/tools/git/pr-merge.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# pr-merge.sh - Merge pull requests on Gitea or GitHub +# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh +source "$SCRIPT_DIR/detect-platform.sh" + +# Default values +PR_NUMBER="" +MERGE_METHOD="squash" +DELETE_BRANCH=false +SKIP_QUEUE_GUARD=false +DRY_RUN=false + +usage() { + cat <&2 + usage + ;; + esac +done + +if [[ -z "$PR_NUMBER" ]]; then + echo "Error: PR number is required (-n)" >&2 + usage +fi + +if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: Invalid PR number '$PR_NUMBER'. PR number must contain digits only." >&2 + exit 1 +fi + +if [[ "$MERGE_METHOD" != "squash" ]]; then + echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2 + exit 1 +fi + +PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")" +BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')" +if [[ "$BASE_BRANCH" != "main" ]]; then + echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 + exit 1 +fi + +if [[ "$SKIP_QUEUE_GUARD" != true ]]; then + "$SCRIPT_DIR/ci-queue-wait.sh" \ + --purpose merge \ + -B "$BASE_BRANCH" \ + -t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \ + -i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}" +fi + +PLATFORM=$(detect_platform) +OWNER=$(get_repo_owner) +REPO=$(get_repo_name) + +is_known_tea_empty_identity_failure() { + local error_file="$1" + + python3 - "$error_file" <<'PY' +import re +import sys + +with open(sys.argv[1], encoding="utf-8", errors="replace") as handle: + error = handle.read() + +known_empty_identity = re.search( + r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]", + error, + flags=re.IGNORECASE | re.DOTALL, +) +raise SystemExit(0 if known_empty_identity else 1) +PY +} + +merge_gitea_with_api() { + local host="$1" api_url token basic_auth body_file raw_code payload + api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" + mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" + body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX") + payload='{"Do":"squash"}' + + token=$(get_gitea_token "$host" || true) + if [[ -n "$token" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ + -X POST \ + -H "User-Agent: curl/8" \ + -H "Authorization: token $token" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$api_url" || true) + if [[ "$raw_code" =~ ^2 ]]; then + rm -f "$body_file" + return 0 + fi + fi + + basic_auth=$(get_gitea_basic_auth "$host" || true) + if [[ -n "$basic_auth" ]]; then + raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ + -X POST \ + -u "$basic_auth" \ + -H "User-Agent: curl/8" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$api_url" || true) + if [[ "$raw_code" =~ ^2 ]]; then + rm -f "$body_file" + return 0 + fi + fi + + python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2 +import json +import sys +code, path = sys.argv[1], sys.argv[2] +try: + with open(path, encoding="utf-8", errors="replace") as handle: + raw = handle.read(500) + data = json.loads(raw) if raw else {} + message = data.get("message") or data.get("error") or raw or "empty response" +except Exception: + try: + message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response" + except Exception: + message = "unreadable response" +print(f"Error: Gitea API merge failed with HTTP {code}: {message}") +PY + rm -f "$body_file" + return 1 +} + +if [[ "$DRY_RUN" == true ]]; then + if [[ "$PLATFORM" == "gitea" ]]; then + HOST=$(get_remote_host) || { + echo "Error: Cannot determine host from origin remote URL" >&2 + exit 1 + } + TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" + if [[ -n "$TEA_LOGIN" ]]; then + echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)." + else + echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)." + fi + else + echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)." + fi + exit 0 +fi + +case "$PLATFORM" in + github) + cmd=(gh pr merge "$PR_NUMBER" --squash) + [[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch) + "${cmd[@]}" + ;; + gitea) + HOST=$(get_remote_host) || { + echo "Error: Cannot determine host from origin remote URL" >&2 + exit 1 + } + TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" + + if [[ -n "$TEA_LOGIN" ]]; then + mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" + TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX") + if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then + rm -f "$TEA_ERROR_FILE" + elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then + cat "$TEA_ERROR_FILE" >&2 + echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2 + rm -f "$TEA_ERROR_FILE" + merge_gitea_with_api "$HOST" + else + cat "$TEA_ERROR_FILE" >&2 + rm -f "$TEA_ERROR_FILE" + exit 1 + fi + else + echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2 + merge_gitea_with_api "$HOST" + fi + + # Delete branch after merge if requested + if [[ "$DELETE_BRANCH" == true ]]; then + echo "Note: Branch deletion after merge may need to be done separately with tea" >&2 + fi + ;; + *) + echo "Error: Could not detect git platform" >&2 + exit 1 + ;; +esac + +echo "PR #$PR_NUMBER merged successfully" diff --git a/infra/fleet/tools/git/test-pr-merge-message-field.sh b/infra/fleet/tools/git/test-pr-merge-message-field.sh new file mode 100755 index 00000000..291a299e --- /dev/null +++ b/infra/fleet/tools/git/test-pr-merge-message-field.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# Regression harness for the optional, identity-checked Gitea squash message. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUBJECT="$SCRIPT_DIR/pr-merge.sh" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}" +ORIG_PATH="$PATH" +failures=0 + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +fail() { + echo "FAIL $1" >&2 + failures=$((failures + 1)) +} + +make_case() { + local name="$1" case_dir + case_dir="$WORK_DIR/$name" + mkdir -p "$case_dir/bin" "$case_dir/agent" + cp "$SUBJECT" "$case_dir/pr-merge.sh" + chmod +x "$case_dir/pr-merge.sh" + + cat > "$case_dir/detect-platform.sh" <<'SH' +#!/usr/bin/env bash +detect_platform() { PLATFORM=gitea; printf 'gitea\n'; } +get_repo_owner() { printf 'acme\n'; } +get_repo_name() { printf 'widgets\n'; } +get_remote_host() { printf 'git.example.test\n'; } +get_gitea_token() { printf 'fixture-token\n'; } +get_gitea_basic_auth() { return 1; } +get_gitea_login_for_host() { return 1; } +SH + + cat > "$case_dir/pr-metadata.sh" <<'SH' +#!/usr/bin/env bash +cat <<'JSON' +{ + "number": 42, + "title": "Preserve both branch authors", + "author": "poster", + "baseRefName": "main" +} +JSON +SH + + cat > "$case_dir/ci-queue-wait.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + + cat > "$case_dir/bin/curl" <<'SH' +#!/usr/bin/env bash +set -eu + +url="" +method="GET" +out_file="" +data="" +config="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out_file="$2" + shift 2 + ;; + -w) + shift 2 + ;; + -X) + method="$2" + shift 2 + ;; + -d|--data|--data-binary) + data="$2" + shift 2 + ;; + -K|--config) + if [[ "$2" == "-" ]]; then + config=$(cat) + fi + shift 2 + ;; + -H|--header|-u|--user) + if [[ "$2" == *"fixture-token"* ]]; then + : > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}" + fi + shift 2 + ;; + http://*|https://*) + url="$1" + shift + ;; + *) + shift + ;; + esac +done + +printf '%s %s\n' "$method" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}" +if [[ "$config" == *"Authorization: token fixture-token"* ]]; then + : > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}" +fi + +case "$url" in + */pulls/42/commits*) + case "${MOSAIC_TEST_COMMITS_MODE:?}" in + verified) + body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}},{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+branch@example.test"}},"author":{"login":"alice"}}]' + ;; + null-login) + body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"unresolved@example.test"}},"author":null}]' + ;; + single) + body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + ;; + *) + echo "unknown commits mode" >&2 + exit 2 + ;; + esac + code=200 + ;; + */pulls/42/merge) + body='{}' + code=200 + printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}" + ;; + */users/*) + body='{"message":"not found"}' + code=404 + ;; + *) + body='{"message":"unexpected URL"}' + code=500 + ;; +esac + +if [[ -n "$out_file" ]]; then + printf '%s' "$body" > "$out_file" +else + printf '%s' "$body" +fi +printf '%s' "$code" +SH + + chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \ + "$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" + printf '%s\n' "$case_dir" +} + +run_case() { + local case_dir="$1" mode="$2" + shift 2 + MOSAIC_TEST_COMMITS_MODE="$mode" \ + MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \ + MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \ + MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \ + MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \ + AGENT_WORK_ROOT="$case_dir/agent" \ + PATH="$case_dir/bin:$ORIG_PATH" \ + "$case_dir/pr-merge.sh" -n 42 "$@" +} + +# Verified multi-author path: the non-poster trailer is built from one commit's +# linked author.login and that same commit's author email. No /users lookup. +verified_dir=$(make_case verified) +set +e +verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +verified_rc=$? +set -e +if [[ "$verified_rc" -ne 0 ]]; then + fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output" +elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then + fail "verified multi-author merge did not reach the API payload" +else + python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields" +import json +import sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload == { + "Do": "squash", + "MergeTitleField": "Preserve both branch authors", + "MergeMessageField": "Co-authored-by: alice ", +}, payload +PY +fi +[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config" +[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv" +if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then + fail "verified path performed a forbidden second /users lookup" +fi + +# BLOCK path: a commit email exists but author.login is null. It must name both +# facts, name the escalation principal, and never reach the merge endpoint. +null_dir=$(make_case null-login) +set +e +null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1) +null_rc=$? +set -e +[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK" +[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic" +[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact" +[[ "$null_output" == *"unresolved@example.test"* ]] || fail "null-login author omitted the commit email fact" +[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal" +[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API" + +# Negative control: ordinary single-author merge remains byte-for-byte payload +# compatible and hardcoded to squash, with no optional message fields. +single_dir=$(make_case single) +set +e +single_output=$(run_case "$single_dir" single 2>&1) +single_rc=$? +set -e +if [[ "$single_rc" -ne 0 ]]; then + fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output" +elif [[ ! -s "$single_dir/merge-payload.json" ]]; then + fail "ordinary single-author merge did not reach the API payload" +else + python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed" +import json +import sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload == {"Do": "squash"}, payload +PY +fi +[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config" +[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv" + +# Squash is not defaultable: an explicit non-squash method must remain refused. +method_dir=$(make_case method-refusal) +set +e +method_output=$(run_case "$method_dir" single -m merge 2>&1) +method_rc=$? +set -e +[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed" +[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic" +[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API" + +if [[ "$failures" -ne 0 ]]; then + echo "pr-merge message-field regression failed ($failures assertions)" >&2 + exit 1 +fi + +echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"