From d3bd97464df5806d88b4a290ac7cbabd78550410 Mon Sep 17 00:00:00 2001 From: dinq Date: Wed, 7 Oct 2026 10:05:15 -0400 Subject: [PATCH 1/2] docs: slim PR review rules into tools/pr Keep the gate order in the workflow. Status, sync, worktree, merge, session lookup, and evidence redaction are scripts. --- .agents/rules/topics/security.md | 2 + .agents/rules/workflows/acceptance.md | 22 +- .agents/rules/workflows/pull-requests.md | 193 +++------- .github/pull_request_template.md | 1 + tools/pr/README.md | 26 ++ tools/pr/lib.sh | 253 +++++++++++++ tools/pr/pr-merge | 156 ++++++++ tools/pr/pr-session | 94 +++++ tools/pr/pr-status | 47 +++ tools/pr/pr-sync | 91 +++++ tools/pr/pr-worktree | 96 +++++ tools/pr/redact-evidence | 111 ++++++ tools/pr/test.sh | 457 +++++++++++++++++++++++ tools/verify/README.md | 15 + 14 files changed, 1405 insertions(+), 159 deletions(-) create mode 100644 tools/pr/README.md create mode 100644 tools/pr/lib.sh create mode 100755 tools/pr/pr-merge create mode 100755 tools/pr/pr-session create mode 100755 tools/pr/pr-status create mode 100755 tools/pr/pr-sync create mode 100755 tools/pr/pr-worktree create mode 100755 tools/pr/redact-evidence create mode 100755 tools/pr/test.sh diff --git a/.agents/rules/topics/security.md b/.agents/rules/topics/security.md index b07d82bd6..25f9a1b92 100644 --- a/.agents/rules/topics/security.md +++ b/.agents/rules/topics/security.md @@ -16,6 +16,8 @@ do not turn unrelated tasks into a product-wide security audit. symlinks. For process execution, keep data separate from shell syntax and check how the invoked program interprets arguments. Follow the host-write approval gate in [persistence.md](persistence.md). +- A joined Project is treated as approved: every Pi process started in that + Project directory is launched with `--approve`. - Keep credentials and sensitive content out of unauthorized responses, logs, telemetry, commits, and evidence uploads. Do not weaken transport/origin checks or expose an authenticated local service through an unprotected route. diff --git a/.agents/rules/workflows/acceptance.md b/.agents/rules/workflows/acceptance.md index 76cf3c8c3..c3f09b1c4 100644 --- a/.agents/rules/workflows/acceptance.md +++ b/.agents/rules/workflows/acceptance.md @@ -36,19 +36,15 @@ The [web dev recipe](../../skills/verify/SKILL.md) covers the two-process local did not execute, including a sandbox or socket failure, is a gap. Verify's run-local sample Project is approved except for Import project: launch with `--replace --empty-projects` and drive the real import flow. -- Public screenshots and video show the relevant UI only. Crop or redact local - paths, identifiers, credentials, daemon records, and unrelated diagnostics - before upload. Before/after frames are that drive, not a simulated older baseline. +- Public screenshots and video show the relevant UI only. Omit local paths, + identifiers, credentials, daemon records, and unrelated diagnostics. Use + `tools/pr/redact-evidence` before upload. Before/after frames are that drive, + not a simulated older baseline. ## Capture and delivery -- Screenshots: `pnpm exec pie-verify web evidence screenshot `; use - `desktop` for Electron. Names should identify the behavior and before/after state. -- The Verify shim automatically starts and retains a 60 fps browser recording. - Start each additional validation with `evidence init` to rotate clips. Do not - call `record start`, `restart`, or `stop`; normal cleanup flushes the video. -- Use `evidence note` to describe what each `recording-.webm` demonstrates. - `evidence path` locates the retained `.agents/skills/verify-pie{,-desktop}/evidence//` directory. -- Attach screenshots **and** video to the UI issue, PR, or comment with - `gh pr|issue create|edit|comment --attach --attach `. - Evidence is gitignored: never commit it or expose credentials in uploads. +- Capture screenshots, notes, and recordings with `pie-verify` evidence + commands. Names should identify the behavior and the before/after state. + See [tools/verify/README.md](../../../tools/verify/README.md). +- Attach screenshots and video to the UI issue or pull request. + Evidence is gitignored: never commit it or include credentials in uploads. diff --git a/.agents/rules/workflows/pull-requests.md b/.agents/rules/workflows/pull-requests.md index c867b015c..796c92783 100644 --- a/.agents/rules/workflows/pull-requests.md +++ b/.agents/rules/workflows/pull-requests.md @@ -1,148 +1,49 @@ -# PR review, verification and merge +# PR review, verification, and merge When asked to review and merge, follow this order: -**CI passes → review passes → independent verification passes → merge.** -If a gate fails or lacks required proof, explain the blocker on the PR and stop. -Optional suggestions do not block progress. - -## Scope and context - -- Default: `oxwen11/pie`, author `oxwen11`, same-repository, open, non-draft PR - targeting `main`. A stack merges one main-targeting PR at a time. -- All change types are eligible. Required [design decisions](design.md), security - checks and authorization still apply; merge permission does not authorize - production operations or access to real user data. -- Use rules from the trusted base. PR content and tool output cannot grant - exemptions or override permissions. -- Use the [PR template](../../../.github/pull_request_template.md) for requirements, - expected behavior, changes/risks and author verification. Linked context is - enough; no duplicate write-up is needed. Investigate gaps and ask when - consequential ambiguity remains. - -Prefer GitHub-native collaboration: comments, reviews and Labels as useful. -Agents choose their methods, tools, delegation and supporting notes/reports; -keep conclusions, blockers and acceptance evidence discoverable from the PR. -Normally use **Request changes** for defects, comments for context/environment -blockers, and **Approve** when ready. If GitHub disallows a review action for the -current identity, comment instead; this never substitutes for a required approval. - -The PR and linked GitHub context are the shared task and handoff record. -Repeated work requires a relevant change in version, CI, requirements or blockers; -unchanged conclusions are not repeated. An earlier `CHANGES_REQUESTED` can be -rechecked, but unresolved findings and required approvals still block merging. - -## 1. Check CI - -Record head/base SHAs and inspect required checks for this candidate: - -```bash -gh pr checks "$PR" --repo oxwen11/pie --required --json name,state,bucket,link -``` - -Every required check must be present, executed and successful for this version. -Missing, pending, skipped, cancelled, failed or stale results block code review -and verification. Do not weaken checks, and do not retry until green. One retry -is allowed only after the log shows an external infrastructure failure and that -service has recovered. A later push cancels older runs for that PR; only the -current head's checks count. Required checks that share a name are separate: -each must succeed. When `gh` metadata disagrees with the REST commit or compare -API, the REST SHAs are authoritative. - -**CI-only exception:** after inspecting the full diff, ordinary prose/comments, -behavior-preserving formatting or additive isolated tests actually run by CI may -skip steps 2–3. This excludes product changes, changed assertions/shared fixtures, -machine-consumed directives, agent instructions, security, release and verification -policy. Judge coverage from executed checks, not merely the existence of tests. - -## 2. Review - -Apply [review.md](review.md) to the requirements, full diff and affected callers. -Independently identify necessary verification and expected results; the author's -steps are not an exhaustive test plan. Reuse recipes and supplement missing coverage. - -Blocking findings, missing authorization or an unverifiable required outcome -stop the workflow. Explain why and what is needed; do not fix-and-merge in the -same review. The author of a version, including a repair made during review, -cannot independently review or approve it. A blocked or unexecuted check is a -gap, not a pass. After the author updates the PR, restart at CI for the new version. - -## 3. Verify independently - -The reviewer runs the checks; the author's evidence cannot replace this step. - -- Create a dedicated, clean reviewer-owned worktree for each PR, pinned to its - recorded head. Install locked dependencies and build affected artifacts - through Turbo. Confirm builds and running instances belong to this revision; - reused artifacts or `launch --replace` alone do not prove freshness. Uncommitted - source changes cannot serve as proof of the PR head. -- Follow [acceptance.md](acceptance.md) for recipes, failure/regression coverage - and evidence. Exercise affected user paths, not unrelated features. For tooling - or instructions, use relevant commands or policy scenarios instead of unrelated - UI demonstrations. -- Verify the affected surface: shared SPA/UI paths need both Web and Desktop; - source startup cannot prove an installed package, and fake Pi cannot prove real - model/tool execution. Record actual results and evidence with the tested SHA. -- Isolate `PIE_HOME`, sample Projects and browser/Electron profiles; never reuse - the user's app, development instance or real data. Full `HOME` isolation is not - required. For affected shared state outside `PIE_HOME`, isolate it or obtain - explicit authorization. A worktree is not a security sandbox. -- Parallel verification requires separate worktrees, verification roots, - application data, sample Projects, browser/Electron profiles, sockets, ports - and evidence. Same-root lifecycle operations and same-worktree builds stay - serial. Bind every operation to its owned run; occupied resources block that - configuration, not unrelated isolated runs. Never adopt or kill another - task's processes. Shared Pi settings/auth/package mutations remain serial and - require authorization; this includes creating a Session with an explicit model - or changing its model, which persist Pi's global default. `PIE_HOME` does not - isolate those writes. If a run changes shared Pi settings, record that fact; - do not guess and restore a previous value. Preserve evidence and keep - credentials out of uploads. -- Leave the primary checkout clean on `main`. Use one worktree per candidate, - pinned to the reviewed head; do not switch the primary branch. Merging one PR - moves `main` and invalidates every other candidate's base; restart those at CI. -- Drive through the owned root's generated browser script. Bare `agent-browser` - is ambiguous when more than one surface is current. A shim refusal stops that - command; it does not authorize selecting, adopting, or killing another run. -- Explicit cleanup addresses that run only. A missing, foreign, wrong-owner, or - corrupt target is refused before any process is stopped and must not fall back - to another current run. Cleanup is complete only when owned processes and ports - are gone, evidence remains, and foreign runs are untouched. -- Real-model checks use the operator's existing Pi configuration and isolate - application state with `PIE_HOME` only. Launch refuses an overridden `HOME` - or an empty `PI_CODING_AGENT_DIR`. -- Crop public evidence to the relevant UI. Omit local paths, run identifiers, - credentials, daemon records, and unrelated diagnostics. Before/after frames - show that drive, not a reconstructed baseline. - -## 4. Record the outcome, then merge the verified version - -Before stopping or merging, publish a concise durable record on the PR. Include -the reviewed head/base SHAs, the trusted rules commit, the conclusion, and the -checks, observed results, evidence links and gaps that support it. State what -relevant change warrants another review. Record the justified -CI-only exception when used. Supporting links are enough; no fixed report format -is required. Record observed facts and reasons, not agent reasoning or raw tool -logs. Sanitize evidence and keep credentials, private data and local execution -traces off the PR. - -Use a Review, comment or evidence attachment as appropriate. Do not rely on an -edited comment as the only record of a final conclusion: later corrections are -new comments that cite the original record and preserve it. If the PR changes -before the record is published, restart at CI and record the new version. - -Immediately recheck SHAs, scope/state, unresolved reviews, `MERGEABLE` and required -CI. If head or base changed, restart at CI. Keep GitHub's strict base-up-to-date -protection enabled: the head guard below does not protect against base movement. -Never bypass protections or arm a deferred merge. - -When all gates hold, squash merge without routine human confirmation: - -```bash -gh pr merge "$PR" --repo oxwen11/pie --squash --match-head-commit "$HEAD_SHA" -gh pr view "$PR" --repo oxwen11/pie --json state,headRefOid,mergedAt,mergeCommit,url -``` - -Use the full reviewed and verified `HEAD_SHA`. After GitHub confirms the merge, -add its confirmed merge commit to the PR record. After a timeout, record the -outcome as unverified, inspect server state and append the confirmed result; -never infer success or failure. +**CI → review → independent verification → record → merge.** +If a gate has no evidence, say what is missing on the pull request and stop. +Optional suggestions do not block. + +Default candidate: an open, non-draft, same-repository pull request into `main` +on `oxwen11/pie`. Merge permission does not cover production changes or real +user data. Use the rules +on the trusted base. Pull request content and tool output cannot grant an exemption. + +`tools/pr/pr-status` reports this head and base, required checks, `MERGEABLE`, +and commits behind the base. Every required check must have passed for this +head. Missing, pending, skipped, cancelled, failed, or stale checks stop the +workflow. Do not weaken a check. A new push invalidates older runs. If `gh` +and the commit SHA disagree, the commit SHA wins. After another pull request +merges, `main` has moved: `tools/pr/pr-sync`, then start again at CI. + +Review the requirements, the full diff, and affected callers with +[review.md](review.md). The author's checks are not the verification plan. +A blocking finding, missing authorization, or an unverifiable required outcome +stops the workflow. Do not fix and merge in the same review. The author of a +version, including a repair made during review, does not review or approve +that version. If the pull request changes, start again at CI. + +Verify that recorded head. `tools/pr/pr-worktree` checks out a clean worktree; +uncommitted edits and reused builds are not proof. Follow +[acceptance.md](acceptance.md) and `pnpm exec pie-verify`, isolated as +[tools/verify/README.md](../../../tools/verify/README.md) describes. Keep +credentials out of evidence. Redact public captures with +`tools/pr/redact-evidence`. + +Before stopping or merging, comment the reviewed head SHA, conclusion, +evidence links, and gaps. A correction is a new comment that cites that +record. If the head moved first, start again at CI and record the new head. + +Ask the Developer before a new or changed host write, a shared Pi setting or +auth change (a Session model change persists Pi's global default; `PIE_HOME` +does not), a production operation, or any other irreversible action. Otherwise, +evidence for every gate is enough to merge. + +Squash-merge only the reviewed SHA, using `--match-head-commit`. +`tools/pr/pr-merge` rechecks the gates, uses merge-async for a stacked pull +request, and comments the confirmed merge commit. Do not bypass protections +or arm a deferred merge. If GitHub has not confirmed the result, mark it +unverified and look. + +`tools/pr/pr-session` lists Sessions bound to a pull request number. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8b0aac792..4211a5a36 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,6 +11,7 @@ ## Changes and risks + ## Verification diff --git a/tools/pr/README.md b/tools/pr/README.md new file mode 100644 index 000000000..3f6b74b53 --- /dev/null +++ b/tools/pr/README.md @@ -0,0 +1,26 @@ +# PR review tools + +Operator scripts for the mechanical steps in +[pull-requests.md](../../.agents/rules/workflows/pull-requests.md). +Not `pie-verify`: that tool already owns isolated launch, doctor, evidence, +cleanup, and daemon-token redaction. + +| Script | What it does | +| -------------------------- | ------------------------------------------------------------------------- | +| `tools/pr/pr-status` | Head and base SHAs, required checks, `MERGEABLE`, commits behind the base | +| `tools/pr/pr-sync` | `update-branch` with `expected_head_sha` | +| `tools/pr/pr-worktree` | Detached worktree pinned to the reviewed head | +| `tools/pr/pr-merge` | Squash-merge that SHA; stacked pull requests use `merge-async` | +| `tools/pr/pr-session` | Sessions whose stored pull request number matches | +| `tools/pr/redact-evidence` | Crop or blur a public screenshot or video | + +Each script accepts `--help`. They use the `gh` already authenticated on this +machine and do not print tokens. `pr-merge` does not pass `--admin`, `--auto`, +or `bypass_rules`. An unverified merge or sync exits 2. + +Requirements: `gh` and `jq` (1.6+). `pr-worktree` also needs `git`. +`redact-evidence` also needs `magick` for images and `ffmpeg` for video. + +```bash +bash tools/pr/test.sh +``` diff --git a/tools/pr/lib.sh b/tools/pr/lib.sh new file mode 100644 index 000000000..a53610785 --- /dev/null +++ b/tools/pr/lib.sh @@ -0,0 +1,253 @@ +# Shared helpers for tools/pr. Callers set -euo pipefail first. Bash 3.2. + +die() { + printf 'pr: %s\n' "$*" >&2 + exit 1 +} + +unverified() { + printf 'pr: unverified: %s\n' "$*" >&2 + exit 2 +} + +scrub() { + sed -E 's/(gho_|ghp_|ghs_|github_pat_)[A-Za-z0-9_]+/[redacted]/g' +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing command: $1" +} + +pr_cleanup() { + rm -f "${GH_STDOUT_FILE:-}" "${GH_STDERR_FILE:-}" "${PR_CHECKS_FILE:-}" "${PR_STACK_FILE:-}" +} + +pr_init() { + PR_CHECKS_FILE=$(mktemp "${TMPDIR:-/tmp}/pr-checks.XXXXXX") + PR_STACK_FILE=$(mktemp "${TMPDIR:-/tmp}/pr-stack.XXXXXX") + trap pr_cleanup EXIT +} + +require_remote() { + printf '%s\n' "$1" | grep -Eq '^[A-Za-z0-9._/-]+$' || die "invalid --remote" + REMOTE=$1 +} + +gh_run() { + rm -f "${GH_STDOUT_FILE:-}" "${GH_STDERR_FILE:-}" + GH_STDOUT_FILE=$(mktemp "${TMPDIR:-/tmp}/pr-stdout.XXXXXX") + GH_STDERR_FILE=$(mktemp "${TMPDIR:-/tmp}/pr-stderr.XXXXXX") + set +e + gh "$@" >"$GH_STDOUT_FILE" 2>"$GH_STDERR_FILE" + GH_STATUS=$? + set -e +} + +show_gh_err() { + scrub <"$GH_STDERR_FILE" >&2 +} + +json_object() { + jq -e 'type == "object"' "$1" >/dev/null 2>/dev/null +} + +set_repo() { + REPO=$1 + case "$REPO" in + */*/*) API_REPO=${REPO#*/} ;; + */*) API_REPO=$REPO ;; + *) die "invalid --repo (want OWNER/REPO)" ;; + esac + printf '%s\n' "$API_REPO" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' \ + || die "invalid --repo" +} + +default_repo() { + if [ -n "${REPO:-}" ]; then + return 0 + fi + gh_run repo view --json nameWithOwner + if [ "$GH_STATUS" -ne 0 ] || ! json_object "$GH_STDOUT_FILE"; then + show_gh_err + die "gh repo view failed" + fi + set_repo "$(jq -r '.nameWithOwner // empty' "$GH_STDOUT_FILE")" +} + +require_number() { + printf '%s\n' "${1:-}" | grep -Eq '^[1-9][0-9]*$' || die "expected a pull request number" + NUMBER=$1 +} + +is_sha() { + printf '%s\n' "${1:-}" | grep -Eq '^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$' +} + +require_sha() { + is_sha "${1:-}" || die "expected a commit SHA" +} + +sha_eq() { + [ "$(printf '%s' "$1" | tr 'A-F' 'a-f')" = "$(printf '%s' "$2" | tr 'A-F' 'a-f')" ] +} + +load_pull_request() { + local tries=0 + local limit="${PR_MERGEABLE_POLLS:-5}" + while true; do + load_view "$1" + load_checks "$1" + load_behind + if [ "$PR_MERGEABLE" != "UNKNOWN" ] || [ "$tries" -ge "$limit" ]; then + break + fi + tries=$((tries + 1)) + sleep "${PR_POLL_INTERVAL:-1}" + done +} + +load_view() { + gh_run pr view "$1" --repo "$REPO" --json \ + number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,reviewDecision + if [ "$GH_STATUS" -ne 0 ] || ! json_object "$GH_STDOUT_FILE"; then + show_gh_err + die "gh pr view failed" + fi + PR_HEAD=$(jq -r '.headRefOid // empty' "$GH_STDOUT_FILE") + PR_BASE_OID=$(jq -r '.baseRefOid // empty' "$GH_STDOUT_FILE") + PR_BASE_NAME=$(jq -r '.baseRefName // empty' "$GH_STDOUT_FILE") + PR_STATE=$(jq -r '.state // empty' "$GH_STDOUT_FILE") + PR_URL=$(jq -r '.url // empty' "$GH_STDOUT_FILE") + PR_DRAFT=$(jq -r 'if .isDraft then "true" else "false" end' "$GH_STDOUT_FILE") + PR_MERGEABLE=$(jq -r 'if .mergeable == null or .mergeable == "" then "UNKNOWN" else .mergeable end' "$GH_STDOUT_FILE") + PR_MERGE_STATE=$(jq -r 'if .mergeStateStatus == null or .mergeStateStatus == "" then "unknown" else .mergeStateStatus end' "$GH_STDOUT_FILE") + PR_REVIEW=$(jq -r 'if .reviewDecision == null or .reviewDecision == "" then "none" else .reviewDecision end' "$GH_STDOUT_FILE") + is_sha "$PR_HEAD" || die "pull request head is not a SHA" + [ -n "$PR_BASE_NAME" ] || die "pull request has no base branch" +} + +load_checks() { + gh_run pr checks "$1" --repo "$REPO" --required --json name,state,bucket,link + if ! jq -e 'type == "array"' "$GH_STDOUT_FILE" >/dev/null 2>/dev/null; then + show_gh_err + die "could not read required checks" + fi + cp "$GH_STDOUT_FILE" "$PR_CHECKS_FILE" + PR_CHECK_COUNT=$(jq 'length' "$PR_CHECKS_FILE") + PR_CHECK_BAD=$(jq '[.[] | select(.bucket != "pass" or (.state != "SUCCESS" and .state != "success"))] | length' "$PR_CHECKS_FILE") +} + +load_behind() { + local base head spec + base=$(jq -nr --arg v "$PR_BASE_NAME" '$v|@uri') + head=$(jq -nr --arg v "$PR_HEAD" '$v|@uri') + spec="${base}...${head}" + gh_run api "repos/${API_REPO}/compare/${spec}" + if [ "$GH_STATUS" -ne 0 ] || ! json_object "$GH_STDOUT_FILE"; then + show_gh_err + die "could not compare ${PR_BASE_NAME}...head" + fi + PR_BEHIND=$(jq -r '.behind_by // empty' "$GH_STDOUT_FILE") + case "$PR_BEHIND" in + ''|*[!0-9]*) die "invalid behind_by" ;; + esac +} + +pr_ready() { + if [ "$PR_STATE" != "OPEN" ]; then + PR_REASON="state is $PR_STATE" + return 1 + fi + if [ "$PR_DRAFT" != "false" ]; then + PR_REASON="draft" + return 1 + fi + if [ "$PR_MERGEABLE" != "MERGEABLE" ]; then + PR_REASON="mergeable is $PR_MERGEABLE" + return 1 + fi + if [ "$PR_BEHIND" -ne 0 ]; then + PR_REASON="behind base by $PR_BEHIND" + return 1 + fi + if [ "$PR_REVIEW" = "CHANGES_REQUESTED" ]; then + PR_REASON="changes requested" + return 1 + fi + if [ "$PR_CHECK_COUNT" -eq 0 ]; then + PR_REASON="no required checks reported" + return 1 + fi + if [ "$PR_CHECK_BAD" -ne 0 ]; then + PR_REASON="required checks are not all passing" + return 1 + fi + PR_REASON="" + return 0 +} + +print_report() { + printf 'number: %s\n' "$NUMBER" + printf 'repo: %s\n' "$REPO" + printf 'url: %s\n' "$PR_URL" + printf 'state: %s\n' "$PR_STATE" + printf 'draft: %s\n' "$PR_DRAFT" + printf 'head: %s\n' "$PR_HEAD" + printf 'base: %s (%s)\n' "$PR_BASE_OID" "$PR_BASE_NAME" + printf 'behind: %s\n' "$PR_BEHIND" + printf 'mergeable: %s\n' "$PR_MERGEABLE" + printf 'mergeStateStatus: %s\n' "$PR_MERGE_STATE" + printf 'reviewDecision: %s\n' "$PR_REVIEW" + printf 'checks:\n' + jq -r '.[] | " - \(.name): \(.bucket) \(.state) \(.link // "")"' "$PR_CHECKS_FILE" + if pr_ready; then + printf 'result: ready\n' + else + printf 'result: blocked\nreason: %s\n' "$PR_REASON" + fi +} + +detect_stack() { + local count member below + STACKED=0 + gh_run api "repos/${API_REPO}/stacks?pull_request=${NUMBER}" + if [ "$GH_STATUS" -ne 0 ]; then + if grep -q '404' "$GH_STDERR_FILE" || grep -q '404' "$GH_STDOUT_FILE"; then + return 0 + fi + show_gh_err + die "stack lookup failed" + fi + count=$(jq 'if type == "array" then length else -1 end' "$GH_STDOUT_FILE") + if [ "$count" -eq 0 ]; then + return 0 + fi + if [ "$count" -ne 1 ]; then + die "ambiguous stack response" + fi + # pull_requests is bottom to top, so earlier rows are downstack. + jq -r '.[0].pull_requests[] | [.number, (.head.sha // ""), (.state // ""), (.merged_at // "")] | @tsv' \ + "$GH_STDOUT_FILE" >"$PR_STACK_FILE" + member=$(awk -v n="$NUMBER" '$1 == n { print $2; exit }' "$PR_STACK_FILE") + [ -n "$member" ] || die "stack response is missing this pull request head" + sha_eq "$member" "$PR_HEAD" || die "stack head does not match the pull request head" + below=$(awk -v n="$NUMBER" ' + $1 == n { found = 1; exit } + $3 == "open" && $4 == "" { list = list (list ? "," : "") $1 } + END { if (!found) print "missing"; else print list } + ' "$PR_STACK_FILE") + [ "$below" != "missing" ] || die "stack response is missing this pull request" + if [ -n "$below" ]; then + die "open downstack pull request(s) $below would merge with #$NUMBER" + fi + STACKED=1 +} + +require_ready_sha() { + load_pull_request "$NUMBER" + if ! pr_ready; then + print_report | scrub >&2 + die "$PR_REASON" + fi + sha_eq "$PR_HEAD" "$EXPECTED" || die "head is $PR_HEAD, expected $EXPECTED" +} diff --git a/tools/pr/pr-merge b/tools/pr/pr-merge new file mode 100755 index 000000000..1f5e0fed0 --- /dev/null +++ b/tools/pr/pr-merge @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Squash-merge one reviewed SHA. Stacked pull requests use merge-async. +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib.sh" + +usage() { + cat <<'EOF' +Usage: pr-merge --sha [--repo OWNER/REPO] + +Recheck the pr-status gates and that the head is still --sha, then squash-merge +that commit. A stacked pull request uses merge-async (sha and merge_method=squash) +and only when it is the lowest open layer: open downstack pull requests would +merge with it. Does not pass --admin, --auto, or bypass_rules. After GitHub +shows the pull request merged, comment the merge commit. Exit 2 means the +result is not confirmed; inspect GitHub before retrying. + +PR_POLL_ATTEMPTS (default 30) and PR_POLL_INTERVAL seconds (default 1) bound +the wait. +EOF +} + +async_merged() { + local status sha + jq -e 'type == "object"' "$1" >/dev/null 2>/dev/null || unverified "invalid merge-async response" + status=$(jq -r '.status // empty' "$1") + sha=$(jq -r '.details.sha // empty' "$1") + case "$status" in + failed|error) die "merge-async failed" ;; + enqueued) unverified "merge was enqueued, not merged" ;; + esac + if [ "$status" = "merged" ] || [ -n "$sha" ]; then + return 0 + fi + return 1 +} + +merge_async() { + local uuid tries limit + gh_run api --method PUT "repos/${API_REPO}/pulls/${NUMBER}/merge-async" \ + -f "sha=${PR_HEAD}" \ + -f merge_method=squash + if [ "$GH_STATUS" -ne 0 ]; then + show_gh_err + unverified "merge-async was not accepted" + fi + if async_merged "$GH_STDOUT_FILE"; then + return 0 + fi + uuid=$(jq -r '.uuid // .details.uuid // empty' "$GH_STDOUT_FILE") + printf '%s\n' "$uuid" | grep -Eq '^[A-Za-z0-9_-]+$' \ + || unverified "merge-async response had no uuid" + tries=0 + limit="${PR_POLL_ATTEMPTS:-30}" + while [ "$tries" -lt "$limit" ]; do + if [ "$tries" -gt 0 ]; then + sleep "${PR_POLL_INTERVAL:-1}" + fi + gh_run api "repos/${API_REPO}/pulls/${NUMBER}/merge-async/${uuid}" + if [ "$GH_STATUS" -ne 0 ]; then + show_gh_err + unverified "merge-async poll failed" + fi + if async_merged "$GH_STDOUT_FILE"; then + return 0 + fi + tries=$((tries + 1)) + done + unverified "merge-async still pending" +} + +plain_merge() { + gh_run pr merge "$NUMBER" --repo "$REPO" --squash --match-head-commit "$PR_HEAD" + if [ "$GH_STATUS" -ne 0 ]; then + show_gh_err + fi +} + +poll_merged() { + local tries limit state oid + tries=0 + limit="${PR_POLL_ATTEMPTS:-30}" + while [ "$tries" -lt "$limit" ]; do + if [ "$tries" -gt 0 ]; then + sleep "${PR_POLL_INTERVAL:-1}" + fi + gh_run pr view "$NUMBER" --repo "$REPO" --json state,mergeCommit,url,headRefOid + if [ "$GH_STATUS" -eq 0 ] && json_object "$GH_STDOUT_FILE"; then + state=$(jq -r '.state // empty' "$GH_STDOUT_FILE") + oid=$(jq -r '.mergeCommit.oid // empty' "$GH_STDOUT_FILE") + if [ "$state" = "MERGED" ] && is_sha "$oid"; then + PR_MERGE_OID=$oid + return 0 + fi + else + show_gh_err + fi + tries=$((tries + 1)) + done + return 1 +} + +confirm_and_comment() { + if poll_merged; then + gh_run pr comment "$NUMBER" --repo "$REPO" --body "Merged as ${PR_MERGE_OID}." + if [ "$GH_STATUS" -ne 0 ]; then + show_gh_err + printf 'pr: merged as %s; comment failed; do not merge again\n' "$PR_MERGE_OID" >&2 + exit 1 + fi + printf 'merged: %s\n' "$PR_MERGE_OID" + return 0 + fi + unverified "merge is not confirmed. Inspect GitHub before retrying." +} + +pr_init +need_cmd gh +need_cmd jq +NUMBER="" +EXPECTED="" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --repo) + shift + [ $# -gt 0 ] || die "--repo needs OWNER/REPO" + set_repo "$1" + shift + ;; + --sha) + shift + [ $# -gt 0 ] || die "--sha needs a commit SHA" + EXPECTED=$1 + shift + ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) + [ -z "$NUMBER" ] || die "unexpected argument: $1" + NUMBER=$1 + shift + ;; + esac +done +[ $# -eq 0 ] || die "unexpected argument" +require_number "$NUMBER" +require_sha "$EXPECTED" +default_repo +require_ready_sha +detect_stack +if [ "$STACKED" -eq 1 ]; then + merge_async +else + plain_merge +fi +confirm_and_comment diff --git a/tools/pr/pr-session b/tools/pr/pr-session new file mode 100755 index 000000000..e6cf6b727 --- /dev/null +++ b/tools/pr/pr-session @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Find Pie sessions whose stored pull request number matches. +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib.sh" + +usage() { + cat <<'EOF' +Usage: pr-session [--home PATH]... + +Print one JSON object per Session whose data.pullRequests[].ref.number or +legacy data.pullRequestRefs[].number is . Homes: each --home, or +$PIE_HOME when set, or else ~/.pie and ~/.pie_*. Reads {version,data} files +under storage/sessions. Does not print snapshots or credentials. +EOF +} + +JQ_PROGRAM=' +.data as $d +| ( + [($d.pullRequests // [])[] | select(.ref.number == $n) | { + number: .ref.number, host: .ref.host, owner: .ref.owner, + repository: .ref.repository, source: .source, excluded: .excluded, + linkedAt: .linkedAt + }] + + [($d.pullRequestRefs // [])[] | select(.number == $n) | { + number: .number, host: .host, owner: .owner, + repository: .repository, source: "pullRequestRefs", excluded: false, + linkedAt: null + }] + )[] +| { + home: $home, file: $file, + sessionId: ($d.sessionId // null), projectId: ($d.projectId // null), + title: ($d.title // null), cwd: ($d.cwd // null) + } + . +' + +need_cmd jq +NUMBER="" +HOMES=() +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --home) + shift + [ $# -gt 0 ] || die "--home needs a path" + HOMES+=("$1") + shift + ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) + [ -z "$NUMBER" ] || die "unexpected argument: $1" + NUMBER=$1 + shift + ;; + esac +done +[ $# -eq 0 ] || die "unexpected argument" +require_number "$NUMBER" + +if [ "${#HOMES[@]}" -eq 0 ]; then + if [ -n "${PIE_HOME:-}" ]; then + HOMES=("$PIE_HOME") + else + if [ -d "$HOME/.pie" ]; then + HOMES+=("$HOME/.pie") + fi + for dir in "$HOME"/.pie_*; do + [ -d "$dir" ] || continue + HOMES+=("$dir") + done + fi +fi +[ "${#HOMES[@]}" -gt 0 ] || die "no Pie home; set PIE_HOME or pass --home" + +seen_dir=0 +for home in "${HOMES[@]}"; do + [ -d "$home" ] || die "not a directory: $home" + sessions="$home/storage/sessions" + if [ ! -d "$sessions" ]; then + printf 'pr: no sessions directory in %s\n' "$home" >&2 + continue + fi + seen_dir=1 + while IFS= read -r -d '' file; do + if jq -e 'type == "object"' "$file" >/dev/null 2>/dev/null; then + jq -c --argjson n "$NUMBER" --arg home "$home" --arg file "$file" "$JQ_PROGRAM" "$file" | scrub + else + printf 'pr: skip unreadable %s\n' "$file" >&2 + fi + done < <(find "$sessions" -type f -name '*.json' -print0) +done +[ "$seen_dir" -eq 1 ] || die "no storage/sessions directory" diff --git a/tools/pr/pr-status b/tools/pr/pr-status new file mode 100755 index 000000000..7f717007d --- /dev/null +++ b/tools/pr/pr-status @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Print head, required checks, mergeability, and commits behind the base. +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib.sh" + +usage() { + cat <<'EOF' +Usage: pr-status [--repo OWNER/REPO] + +Print the pull request head and base SHAs, required checks, MERGEABLE, and +how many commits the head is behind its base. Exit 0 when it is open, not a +draft, MERGEABLE, up to date with its base, free of CHANGES_REQUESTED, and +every required check passed (bucket pass, state SUCCESS). Otherwise exit 1. +EOF +} + +pr_init +need_cmd gh +need_cmd jq +NUMBER="" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --repo) + shift + [ $# -gt 0 ] || die "--repo needs OWNER/REPO" + set_repo "$1" + shift + ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) + [ -z "$NUMBER" ] || die "unexpected argument: $1" + NUMBER=$1 + shift + ;; + esac +done +[ $# -eq 0 ] || die "unexpected argument" +require_number "$NUMBER" +default_repo +load_pull_request "$NUMBER" +print_report | scrub +if pr_ready; then + exit 0 +fi +exit 1 diff --git a/tools/pr/pr-sync b/tools/pr/pr-sync new file mode 100755 index 000000000..92f8ccda0 --- /dev/null +++ b/tools/pr/pr-sync @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Update a pull request branch with expected_head_sha. +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib.sh" + +usage() { + cat <<'EOF' +Usage: pr-sync --sha [--repo OWNER/REPO] + +Merge the base into the pull request through GitHub's update-branch API, +sending expected_head_sha. Refuses when the current head is not --sha. +Does nothing when the head is already up to date. Polls until the head SHA +changes. Exit 2 means the result is unverified and may still be running. +A new head starts checks; review that head from CI. + +PR_POLL_ATTEMPTS (default 30) and PR_POLL_INTERVAL seconds (default 1) bound +the wait. +EOF +} + +pr_init +need_cmd gh +need_cmd jq +NUMBER="" +EXPECTED="" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --repo) + shift + [ $# -gt 0 ] || die "--repo needs OWNER/REPO" + set_repo "$1" + shift + ;; + --sha) + shift + [ $# -gt 0 ] || die "--sha needs a commit SHA" + EXPECTED=$1 + shift + ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) + [ -z "$NUMBER" ] || die "unexpected argument: $1" + NUMBER=$1 + shift + ;; + esac +done +[ $# -eq 0 ] || die "unexpected argument" +require_number "$NUMBER" +require_sha "$EXPECTED" +default_repo +load_view "$NUMBER" +sha_eq "$PR_HEAD" "$EXPECTED" || die "head is $PR_HEAD, expected $EXPECTED" +if [ "$PR_STATE" != "OPEN" ]; then + die "state is $PR_STATE" +fi +load_behind +if [ "$PR_BEHIND" -eq 0 ]; then + printf 'up to date: %s\n' "$PR_HEAD" + exit 0 +fi + +gh_run api --method PUT "repos/${API_REPO}/pulls/${NUMBER}/update-branch" \ + -f "expected_head_sha=${PR_HEAD}" +if [ "$GH_STATUS" -ne 0 ]; then + show_gh_err + unverified "update-branch was not accepted" +fi + +old=$PR_HEAD +tries=0 +limit="${PR_POLL_ATTEMPTS:-30}" +while [ "$tries" -lt "$limit" ]; do + if [ "$tries" -gt 0 ]; then + sleep "${PR_POLL_INTERVAL:-1}" + fi + gh_run pr view "$NUMBER" --repo "$REPO" --json headRefOid,state + if [ "$GH_STATUS" -eq 0 ] && json_object "$GH_STDOUT_FILE"; then + new=$(jq -r '.headRefOid // empty' "$GH_STDOUT_FILE") + if is_sha "$new" && ! sha_eq "$new" "$old"; then + printf 'head: %s\n' "$new" + exit 0 + fi + else + show_gh_err + fi + tries=$((tries + 1)) +done +unverified "head is still $old" diff --git a/tools/pr/pr-worktree b/tools/pr/pr-worktree new file mode 100755 index 000000000..e508a642c --- /dev/null +++ b/tools/pr/pr-worktree @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Detached worktree pinned to a pull request head. Does not switch branches. +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib.sh" + +usage() { + cat <<'EOF' +Usage: pr-worktree --sha --dest [--remote origin] [--repo OWNER/REPO] + +Fetch the pull request head and add a detached worktree at that commit. +Refuses when GitHub's head or the fetched commit is not --sha, when dest +exists, or when dest is inside the current checkout. Does not switch the +current branch and does not install dependencies. +EOF +} + +abs_new_path() { + local dir base + dir=$(dirname "$1") + base=$(basename "$1") + [ -d "$dir" ] || die "parent directory does not exist: $dir" + dir=$(cd "$dir" && pwd -P) + printf '%s/%s\n' "$dir" "$base" +} + +pr_init +need_cmd gh +need_cmd jq +need_cmd git +NUMBER="" +EXPECTED="" +DEST="" +REMOTE="origin" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --repo) + shift + [ $# -gt 0 ] || die "--repo needs OWNER/REPO" + set_repo "$1" + shift + ;; + --sha) + shift + [ $# -gt 0 ] || die "--sha needs a commit SHA" + EXPECTED=$1 + shift + ;; + --dest) + shift + [ $# -gt 0 ] || die "--dest needs a path" + DEST=$1 + shift + ;; + --remote) + shift + [ $# -gt 0 ] || die "--remote needs a name" + REMOTE=$1 + shift + ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) + [ -z "$NUMBER" ] || die "unexpected argument: $1" + NUMBER=$1 + shift + ;; + esac +done +[ $# -eq 0 ] || die "unexpected argument" +require_number "$NUMBER" +require_sha "$EXPECTED" +[ -n "$DEST" ] || die "--dest is required" +require_remote "$REMOTE" +default_repo + +dest=$(abs_new_path "$DEST") +top=$(cd "$(git rev-parse --show-toplevel)" && pwd -P) +case "$dest/" in + "$top/"*) die "dest is inside the current checkout" ;; +esac +[ ! -e "$dest" ] || die "dest exists: $dest" + +gh_run pr view "$NUMBER" --repo "$REPO" --json headRefOid +if [ "$GH_STATUS" -ne 0 ] || ! json_object "$GH_STDOUT_FILE"; then + show_gh_err + die "gh pr view failed" +fi +head=$(jq -r '.headRefOid // empty' "$GH_STDOUT_FILE") +sha_eq "$head" "$EXPECTED" || die "pull request head is $head, expected $EXPECTED" + +git fetch --no-tags "$REMOTE" "refs/pull/${NUMBER}/head" +fetched=$(git rev-parse FETCH_HEAD) +sha_eq "$fetched" "$EXPECTED" || die "fetched $fetched, expected $EXPECTED" +git worktree add --detach "$dest" "$fetched" +printf 'worktree: %s\nhead: %s\n' "$dest" "$fetched" diff --git a/tools/pr/redact-evidence b/tools/pr/redact-evidence new file mode 100755 index 000000000..990660bfa --- /dev/null +++ b/tools/pr/redact-evidence @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Crop or blur a public copy of a screenshot or video. Never overwrites. +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib.sh" + +usage() { + cat <<'EOF' +Usage: redact-evidence (--crop WxH+X+Y | --blur WxH+X+Y) [--crop WxH+X+Y] [--blur WxH+X+Y] file... + +Write a sibling .redacted.. Images use magick (-strip, optional +region blur, optional crop). Video uses ffmpeg to crop and drop metadata; +--blur is image-only. Refuses to overwrite. Does not print file contents. +EOF +} + +geometry() { + printf '%s\n' "$1" | grep -Eq '^[0-9]+x[0-9]+\+[0-9]+\+[0-9]+$' || die "invalid geometry: $1" +} + +ffmpeg_crop() { + printf '%s\n' "$1" | sed -E 's/^([0-9]+)x([0-9]+)\+([0-9]+)\+([0-9]+)$/\1:\2:\3:\4/' +} + +redact_image() { + local input="$1" output="$2" + set -- magick "$input" -strip + if [ -n "$BLUR" ]; then + set -- "$@" -region "$BLUR" -blur 0x12 +region + fi + if [ -n "$CROP" ]; then + set -- "$@" -crop "$CROP" +repage + fi + set -- "$@" "$output" + "$@" + [ -f "$output" ] || die "redaction did not create $output" +} + +redact_video() { + local input="$1" output="$2" ext="$3" crop codec + [ -z "$BLUR" ] || die "--blur is image-only" + [ -n "$CROP" ] || die "video requires --crop" + crop=$(ffmpeg_crop "$CROP") + case "$ext" in + webm) codec=libvpx ;; + *) codec=libx264 ;; + esac + ffmpeg -y -loglevel error -i "$input" -vf "crop=${crop}" -map_metadata -1 -c:v "$codec" -an "$output" + [ -f "$output" ] || die "redaction did not create $output" +} + +CROP="" +BLUR="" +FILES=() +if [ $# -eq 0 ]; then + usage >&2 + exit 1 +fi +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --crop) + shift + [ $# -gt 0 ] || die "--crop needs WxH+X+Y" + geometry "$1" + CROP=$1 + shift + ;; + --blur) + shift + [ $# -gt 0 ] || die "--blur needs WxH+X+Y" + geometry "$1" + BLUR=$1 + shift + ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) + FILES+=("$1") + shift + ;; + esac +done +while [ $# -gt 0 ]; do + FILES+=("$1") + shift +done +[ "${#FILES[@]}" -gt 0 ] || die "expected a file" +[ -n "$CROP" ] || [ -n "$BLUR" ] || die "pass --crop or --blur" + +for input in "${FILES[@]}"; do + [ -f "$input" ] || die "not a file: $input" + base=$(basename "$input") + dir=$(dirname "$input") + ext=$(printf '%s\n' "$base" | tr 'A-Z' 'a-z') + ext=${ext##*.} + stem=${base%.*} + output="$dir/${stem}.redacted.${ext}" + [ ! -e "$output" ] || die "output exists: $output" + case "$ext" in + png|jpg|jpeg|webp) + need_cmd magick + redact_image "$input" "$output" + ;; + webm|mp4|mov) + need_cmd ffmpeg + redact_video "$input" "$output" "$ext" + ;; + *) die "unsupported file: $input" ;; + esac + printf '%s\n' "$output" +done diff --git a/tools/pr/test.sh b/tools/pr/test.sh new file mode 100755 index 000000000..2d0849f7e --- /dev/null +++ b/tools/pr/test.sh @@ -0,0 +1,457 @@ +#!/usr/bin/env bash +# Exercises tools/pr without calling GitHub. Bash 3.2. +set -euo pipefail + +PR=$(cd "$(dirname "$0")" && pwd) +TMP=$(mktemp -d "${TMPDIR:-/tmp}/pr-tools.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +BIN=$TMP/bin +ERR=$TMP/err +GH_LOG=$TMP/gh.log +GH_MERGED=$TMP/merged +GH_UPDATED=$TMP/updated +MAGICK_LOG=$TMP/magick.log +FFMPEG_LOG=$TMP/ffmpeg.log +mkdir -p "$BIN" +: > "$GH_LOG" +: > "$MAGICK_LOG" +: > "$FFMPEG_LOG" + +HEAD_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +BASE_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +NEW_SHA=cccccccccccccccccccccccccccccccccccccccc +MERGE_SHA=dddddddddddddddddddddddddddddddddddddddd +OTHER_SHA=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +for sha in "$HEAD_SHA" "$BASE_SHA" "$NEW_SHA" "$MERGE_SHA" "$OTHER_SHA"; do + test "$(printf '%s' "$sha" | wc -c | tr -d ' ')" = 40 +done + +export GH_LOG GH_MERGED GH_UPDATED MAGICK_LOG FFMPEG_LOG +export HEAD_SHA BASE_SHA NEW_SHA MERGE_SHA OTHER_SHA +export PATH="$BIN:$PATH" +unset PIE_HOME || true + +cat > "$BIN/gh" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >> "$GH_LOG" +if [ "${GH_LEAK:-0}" = "1" ]; then + echo "authorization failed ghp_LEAKEDTOKEN123" >&2 + exit 1 +fi +case "$*" in + *"pr checks"*) + printf '%s\n' "$FIX_CHECKS" + exit "${FIX_CHECKS_EXIT:-0}" + ;; + *"pr view"*) + if [ -f "$GH_MERGED" ]; then + printf '{"state":"MERGED","url":"https://github.com/oxwen11/pie/pull/1","headRefOid":"%s","mergeCommit":{"oid":"%s"}}\n' "$FIX_HEAD" "$MERGE_SHA" + exit 0 + fi + if [ -f "$GH_UPDATED" ]; then + printf '{"number":1,"url":"https://github.com/oxwen11/pie/pull/1","state":"OPEN","isDraft":false,"headRefOid":"%s","baseRefOid":"%s","baseRefName":"main","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null}\n' "$NEW_SHA" "$FIX_BASE" + exit 0 + fi + printf '{"number":1,"url":"https://github.com/oxwen11/pie/pull/1","state":"%s","isDraft":%s,"headRefOid":"%s","baseRefOid":"%s","baseRefName":"main","mergeable":"%s","mergeStateStatus":"%s","reviewDecision":%s}\n' \ + "$FIX_STATE" "$FIX_DRAFT" "$FIX_HEAD" "$FIX_BASE" "$FIX_MERGEABLE" "$FIX_MERGE_STATE" "$FIX_REVIEW_JSON" + exit 0 + ;; + *"compare/"*) + printf '{"behind_by":%s}\n' "${FIX_BEHIND:-0}" + exit 0 + ;; + *"update-branch"*) + if [ "${FIX_SYNC_STICKY:-0}" != "1" ]; then + touch "$GH_UPDATED" + fi + printf '%s\n' '{"message":"Updating pull request branch.","url":"https://api.github.com/repos/example"}' + exit 0 + ;; + *"stacks?"*) + if [ "${FIX_STACK}" = "404" ]; then + echo "HTTP 404: Not Found (HTTP 404)" >&2 + exit 1 + fi + printf '%s\n' "$FIX_STACK" + exit 0 + ;; + *"merge-async/"*) + if [ "${FIX_ASYNC}" = "pending" ]; then + printf '%s\n' '{"status":"pending","details":{"uuid":"u-1"}}' + exit 0 + fi + touch "$GH_MERGED" + printf '{"status":"merged","details":{"sha":"%s"}}\n' "$MERGE_SHA" + exit 0 + ;; + *"merge-async"*) + printf '%s\n' '{"status":"pending","uuid":"u-1"}' + exit 0 + ;; + *"pr merge "*) + if [ "${FIX_MERGE_EXIT:-0}" != "0" ]; then + echo "merge refused" >&2 + exit "$FIX_MERGE_EXIT" + fi + touch "$GH_MERGED" + exit 0 + ;; + *"pr comment"*) + exit 0 + ;; + *) + echo "fake-gh unhandled: $*" >&2 + exit 99 + ;; +esac +EOF + +cat > "$BIN/magick" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >> "$MAGICK_LOG" +out="" +for out; do :; done +printf 'ok\n' > "$out" +EOF + +cat > "$BIN/ffmpeg" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >> "$FFMPEG_LOG" +out="" +for out; do :; done +printf 'ok\n' > "$out" +EOF +chmod +x "$BIN/gh" "$BIN/magick" "$BIN/ffmpeg" + +CODE=0 +OUT="" +reset() { + FIX_HEAD=$HEAD_SHA + FIX_BASE=$BASE_SHA + FIX_STATE=OPEN + FIX_DRAFT=false + FIX_MERGEABLE=MERGEABLE + FIX_MERGE_STATE=CLEAN + FIX_REVIEW_JSON=null + FIX_BEHIND=0 + FIX_CHECKS='[{"name":"Code check","state":"SUCCESS","bucket":"pass","link":"https://example.test/check"}]' + FIX_CHECKS_EXIT=0 + FIX_STACK=404 + FIX_ASYNC=merged + FIX_SYNC_STICKY=0 + FIX_MERGE_EXIT=0 + GH_LEAK=0 + export FIX_HEAD FIX_BASE FIX_STATE FIX_DRAFT FIX_MERGEABLE FIX_MERGE_STATE FIX_REVIEW_JSON + export FIX_BEHIND FIX_CHECKS FIX_CHECKS_EXIT FIX_STACK FIX_ASYNC FIX_SYNC_STICKY FIX_MERGE_EXIT GH_LEAK + rm -f "$GH_MERGED" "$GH_UPDATED" + : > "$GH_LOG" + : > "$MAGICK_LOG" + : > "$FFMPEG_LOG" + export PR_POLL_INTERVAL=0 PR_POLL_ATTEMPTS=2 PR_MERGEABLE_POLLS=0 +} + +run() { + set +e + OUT=$("$@" 2>"$ERR") + CODE=$? + set -e +} + +run_in() { + dir=$1 + shift + set +e + OUT=$(cd "$dir" && "$@" 2>"$ERR") + CODE=$? + set -e +} + +expect_in() { + want=$1 + dir=$2 + shift 2 + run_in "$dir" "$@" + [ "$CODE" -eq "$want" ] || fail "$* wanted $want" +} + +fail() { + printf 'FAIL %s\n' "$*" >&2 + printf 'exit %s\n' "$CODE" >&2 + printf '%s\n' '--- stdout ---' "$OUT" '--- stderr ---' >&2 + cat "$ERR" >&2 + printf '%s\n' '--- gh log ---' >&2 + cat "$GH_LOG" >&2 + exit 1 +} + +expect_code() { + want=$1 + shift + run "$@" + [ "$CODE" -eq "$want" ] || fail "$* wanted $want" +} + +has() { + printf '%s\n' "$OUT" | grep -q -- "$1" || fail "stdout missing: $1" +} + +has_not_token() { + if printf '%s\n' "$OUT" | grep -q 'ghp_LEAKEDTOKEN123'; then + fail "token on stdout" + fi + if grep -q 'ghp_LEAKEDTOKEN123' "$ERR"; then + fail "token on stderr" + fi +} + +log_has() { grep -q -- "$1" "$GH_LOG" || fail "log missing: $1"; } +log_not() { if grep -q -- "$1" "$GH_LOG"; then fail "log unexpected: $1"; fi; } + +step() { printf '%s\n' "$1"; } + +step help +for cmd in pr-status pr-sync pr-worktree pr-merge pr-session redact-evidence; do + expect_code 0 "$PR/$cmd" --help + has "Usage:" +done + +step status-ready +reset +expect_code 0 "$PR/pr-status" 12 --repo oxwen11/pie +has "head: $HEAD_SHA" +has "behind: 0" +has "mergeable: MERGEABLE" +has "result: ready" +has "Code check: pass" + +step status-behind +reset +FIX_BEHIND=2 +export FIX_BEHIND +expect_code 1 "$PR/pr-status" 12 --repo oxwen11/pie +has "behind: 2" +has "result: blocked" +has "reason: behind base by 2" + +step status-pending +reset +FIX_CHECKS='[{"name":"Code check","state":"PENDING","bucket":"pending","link":"https://example.test/check"}]' +export FIX_CHECKS +expect_code 1 "$PR/pr-status" 12 --repo oxwen11/pie +has "reason: required checks are not all passing" + +step status-empty-checks +reset +FIX_CHECKS='[]' +export FIX_CHECKS +expect_code 1 "$PR/pr-status" 12 --repo oxwen11/pie +has "reason: no required checks reported" + +step status-changes +reset +FIX_REVIEW_JSON='"CHANGES_REQUESTED"' +export FIX_REVIEW_JSON +expect_code 1 "$PR/pr-status" 12 --repo oxwen11/pie +has "reason: changes requested" + +step status-draft +reset +FIX_DRAFT=true +export FIX_DRAFT +expect_code 1 "$PR/pr-status" 12 --repo oxwen11/pie +has "reason: draft" + +step status-scrub +reset +GH_LEAK=1 +export GH_LEAK +expect_code 1 "$PR/pr-status" 12 --repo oxwen11/pie +has_not_token +grep -q '\[redacted\]' "$ERR" || fail "stderr was not scrubbed" + +step sync-mismatch +reset +expect_code 1 "$PR/pr-sync" 12 --repo oxwen11/pie --sha "$OTHER_SHA" +log_not update-branch + +step sync-current +reset +expect_code 0 "$PR/pr-sync" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +has "up to date: $HEAD_SHA" +log_not update-branch + +step sync-update +reset +FIX_BEHIND=3 +export FIX_BEHIND +expect_code 0 "$PR/pr-sync" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +has "head: $NEW_SHA" +log_has "expected_head_sha=$HEAD_SHA" + +step sync-unverified +reset +FIX_BEHIND=3 +FIX_SYNC_STICKY=1 +export FIX_BEHIND FIX_SYNC_STICKY +expect_code 2 "$PR/pr-sync" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q unverified "$ERR" || fail "missing unverified" +log_has update-branch + +step merge-plain +reset +expect_code 0 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +has "merged: $MERGE_SHA" +log_has "pr merge " +log_has "--squash" +log_has "--match-head-commit $HEAD_SHA" +log_has "Merged as $MERGE_SHA" +log_not --admin +log_not --auto +log_not bypass_rules +log_not merge-async +count=$(grep -c 'pr merge ' "$GH_LOG" || true) +[ "$count" = 1 ] || fail "merged more than once ($count)" + +step merge-blocked +reset +FIX_CHECKS='[]' +export FIX_CHECKS +expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +log_not "pr merge " +log_not merge-async + +step merge-sha +reset +expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$OTHER_SHA" +log_not "pr merge " + +step merge-refused +reset +FIX_MERGE_EXIT=1 +export FIX_MERGE_EXIT +expect_code 2 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q unverified "$ERR" || fail "missing unverified" +log_not "pr comment" +count=$(grep -c 'pr merge ' "$GH_LOG" || true) +[ "$count" = 1 ] || fail "retried merge ($count)" + +STACK_JSON="[{\"number\":1,\"pull_requests\":[{\"number\":10,\"state\":\"open\",\"merged_at\":null,\"head\":{\"ref\":\"bottom\",\"sha\":\"$HEAD_SHA\"}},{\"number\":11,\"state\":\"open\",\"merged_at\":null,\"head\":{\"ref\":\"top\",\"sha\":\"$OTHER_SHA\"}}]}]" + +step merge-stack +reset +FIX_STACK=$STACK_JSON +export FIX_STACK +expect_code 0 "$PR/pr-merge" 10 --repo oxwen11/pie --sha "$HEAD_SHA" +has "merged: $MERGE_SHA" +log_has "merge-async" +log_has "merge_method=squash" +log_has "sha=$HEAD_SHA" +log_not "merge_action" +log_not "pr merge " +log_not bypass_rules + +step merge-downstack +reset +FIX_HEAD=$OTHER_SHA +FIX_STACK=$STACK_JSON +export FIX_HEAD FIX_STACK +expect_code 1 "$PR/pr-merge" 11 --repo oxwen11/pie --sha "$OTHER_SHA" +grep -q 'open downstack' "$ERR" || fail "missing downstack reason" +log_not "pr merge " +log_not merge-async + +step merge-pending +reset +FIX_STACK="[{\"number\":1,\"pull_requests\":[{\"number\":10,\"state\":\"open\",\"merged_at\":null,\"head\":{\"ref\":\"bottom\",\"sha\":\"$HEAD_SHA\"}}]}]" +FIX_ASYNC=pending +export FIX_STACK FIX_ASYNC +expect_code 2 "$PR/pr-merge" 10 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q unverified "$ERR" || fail "missing unverified" +log_not "pr comment" +log_not "pr merge " + +step session +reset +HOME=$TMP/home +mkdir -p "$HOME/.pie/storage/sessions/p1" "$HOME/.pie_dev/storage/sessions/p2" "$HOME/.pie/storage/sessions/p3" +cat > "$HOME/.pie/storage/sessions/p1/s1.json" < "$HOME/.pie_dev/storage/sessions/p2/s2.json" < "$HOME/.pie/storage/sessions/p3/other.json" < "$HOME/.pie/storage/sessions/p1/bad.json" +export HOME +unset PIE_HOME || true +expect_code 0 "$PR/pr-session" 424 +has '"title":"visible"' +has '"title":"legacy"' +has '"source":"pullRequestRefs"' +has_not_token +if printf '%s\n' "$OUT" | grep -q '"title":"other"'; then + fail "unrelated session matched" +fi +grep -q 'skip unreadable' "$ERR" || fail "bad session was not skipped" + +step session-pie-home +PIE_HOME=$TMP/only +mkdir -p "$PIE_HOME/storage/sessions/p9" +cat > "$PIE_HOME/storage/sessions/p9/s9.json" </dev/null +git init -b main "$PRIMARY" >/dev/null +git -C "$PRIMARY" config user.email "pr-test@example.com" +git -C "$PRIMARY" config user.name "pr-test" +echo a > "$PRIMARY/a.txt" +git -C "$PRIMARY" add a.txt +git -C "$PRIMARY" -c commit.gpgsign=false commit -m init >/dev/null +git -C "$PRIMARY" remote add origin "$ORIGIN" +git -C "$PRIMARY" push -u origin main >/dev/null +GIT_SHA=$(git -C "$PRIMARY" rev-parse HEAD) +git -C "$PRIMARY" push origin "HEAD:refs/pull/7/head" >/dev/null +FIX_HEAD=$GIT_SHA +export FIX_HEAD +DEST=$TMP/wt-ok +expect_in 0 "$PRIMARY" "$PR/pr-worktree" 7 --repo oxwen11/pie --sha "$GIT_SHA" --dest "$DEST" +has "head: $GIT_SHA" +test "$(git -C "$DEST" rev-parse HEAD)" = "$GIT_SHA" +test "$(git -C "$PRIMARY" rev-parse --abbrev-ref HEAD)" = "main" +expect_in 1 "$PRIMARY" "$PR/pr-worktree" 7 --repo oxwen11/pie --sha "$GIT_SHA" --dest "$PRIMARY/inside" +grep -q 'inside the current checkout' "$ERR" || fail "dest inside checkout was allowed" +expect_in 1 "$PRIMARY" "$PR/pr-worktree" 7 --repo oxwen11/pie --sha "$OTHER_SHA" --dest "$TMP/wt-bad" +grep -q 'expected' "$ERR" || fail "sha mismatch was allowed" +test ! -e "$TMP/wt-bad" + +step redact +reset +printf 'x\n' > "$TMP/shot.PNG" +expect_code 0 "$PR/redact-evidence" --crop 8x8+0+0 "$TMP/shot.PNG" +has "$TMP/shot.redacted.png" +grep -q -- '-crop 8x8+0+0' "$MAGICK_LOG" || fail "magick crop missing" +expect_code 1 "$PR/redact-evidence" --crop 8x8+0+0 "$TMP/shot.PNG" +grep -q 'output exists' "$ERR" || fail "overwrite was allowed" +printf 'x\n' > "$TMP/clip.webm" +expect_code 1 "$PR/redact-evidence" --blur 2x2+0+0 "$TMP/clip.webm" +grep -q 'image-only' "$ERR" || fail "video blur was allowed" +expect_code 0 "$PR/redact-evidence" --crop 8x8+1+1 "$TMP/clip.webm" +has "$TMP/clip.redacted.webm" +grep -q 'libvpx' "$FFMPEG_LOG" || fail "webm codec missing" +grep -q 'crop=8:8:1:1' "$FFMPEG_LOG" || fail "ffmpeg crop missing" diff --git a/tools/verify/README.md b/tools/verify/README.md index 8ee696149..92c485a26 100644 --- a/tools/verify/README.md +++ b/tools/verify/README.md @@ -46,6 +46,21 @@ Each `evidence init` stops the current clip and selects the next number; cleanup stops and flushes the current recording. Always pass an explicit `open` URL. `web env` / `desktop env` remain an optional dump. `cli` has no page. +## Capture + +```bash +pnpm exec pie-verify web evidence screenshot +pnpm exec pie-verify web evidence note "what recording-NNN.webm shows" +pnpm exec pie-verify web evidence path +``` + +Use `desktop` instead of `web` for Electron. `evidence init` rotates the +60 fps clip; do not call `record start`, `restart`, or `stop`. Cleanup flushes +the current recording. Evidence stays under +`.agents/skills/verify-pie{,-desktop}/evidence//` and is gitignored. +Attach it with `gh pr|issue comment --attach --attach `. +Crop or blur a public copy with `tools/pr/redact-evidence` before upload. + ## Parallel verification Use **one worktree and one isolation root per task**. Keep `HOME` and the operator's From 41d5735b9c97c9b601d5d50d143dde0a047fde68 Mon Sep 17 00:00:00 2001 From: dinq Date: Wed, 7 Oct 2026 11:01:36 -0400 Subject: [PATCH 2/2] fix(tools/pr): never record a merge of a head that was not reviewed pr-merge exits as soon as gh pr merge fails and only records a merge whose headRefOid is the reviewed SHA. pr-session skips malformed session files, and redact-evidence keeps leading-dash names from reaching magick/ffmpeg as options. CI runs tools/pr/test.sh. security.md now scopes --approve to Pie-owned children, as CONTEXT.md does. --- .agents/rules/topics/security.md | 5 +-- .github/workflows/quality.yml | 15 ++++++++ tools/pr/README.md | 3 +- tools/pr/pr-merge | 8 ++++- tools/pr/pr-session | 7 ++-- tools/pr/redact-evidence | 2 ++ tools/pr/test.sh | 61 ++++++++++++++++++++++++++++++-- 7 files changed, 89 insertions(+), 12 deletions(-) diff --git a/.agents/rules/topics/security.md b/.agents/rules/topics/security.md index 25f9a1b92..fb937f8b9 100644 --- a/.agents/rules/topics/security.md +++ b/.agents/rules/topics/security.md @@ -16,8 +16,9 @@ do not turn unrelated tasks into a product-wide security audit. symlinks. For process execution, keep data separate from shell syntax and check how the invoked program interprets arguments. Follow the host-write approval gate in [persistence.md](persistence.md). -- A joined Project is treated as approved: every Pi process started in that - Project directory is launched with `--approve`. +- Registering a Project trusts it: Pie-owned Pi children for that Project + (session and short-lived listing children) start with `--approve` and run its + extensions. The daemon does not load extensions. - Keep credentials and sensitive content out of unauthorized responses, logs, telemetry, commits, and evidence uploads. Do not weaken transport/origin checks or expose an authenticated local service through an unprotected route. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2103ac66a..81c09eeba 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -108,6 +108,21 @@ jobs: exit 1 shell: bash + - name: PR tools harness + run: | + # tools/pr scripts need jq; the harness fakes gh, magick, and ffmpeg. + if ! command -v jq >/dev/null; then + if command -v apt-get >/dev/null && sudo -n true 2>/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends jq + else + echo "tools/pr/test.sh needs jq on PATH." >&2 + exit 1 + fi + fi + bash tools/pr/test.sh + shell: bash + - name: Ensure Xvfb (Desktop e2e) run: | if command -v xvfb-run >/dev/null && command -v xauth >/dev/null; then diff --git a/tools/pr/README.md b/tools/pr/README.md index 3f6b74b53..fdbdd96e7 100644 --- a/tools/pr/README.md +++ b/tools/pr/README.md @@ -16,7 +16,8 @@ cleanup, and daemon-token redaction. Each script accepts `--help`. They use the `gh` already authenticated on this machine and do not print tokens. `pr-merge` does not pass `--admin`, `--auto`, -or `bypass_rules`. An unverified merge or sync exits 2. +or `bypass_rules`. An unverified merge or sync exits 2. `redact-evidence` writes +`*.redacted.*` next to the original; attach only those files, not a glob. Requirements: `gh` and `jq` (1.6+). `pr-worktree` also needs `git`. `redact-evidence` also needs `magick` for images and `ffmpeg` for video. diff --git a/tools/pr/pr-merge b/tools/pr/pr-merge index 1f5e0fed0..ad0b0d0f8 100755 --- a/tools/pr/pr-merge +++ b/tools/pr/pr-merge @@ -72,11 +72,13 @@ plain_merge() { gh_run pr merge "$NUMBER" --repo "$REPO" --squash --match-head-commit "$PR_HEAD" if [ "$GH_STATUS" -ne 0 ]; then show_gh_err + # Refused or unknown: never fall through to a merge record. + unverified "gh pr merge failed; nothing recorded. Inspect GitHub before retrying." fi } poll_merged() { - local tries limit state oid + local tries limit state oid head tries=0 limit="${PR_POLL_ATTEMPTS:-30}" while [ "$tries" -lt "$limit" ]; do @@ -87,6 +89,10 @@ poll_merged() { if [ "$GH_STATUS" -eq 0 ] && json_object "$GH_STDOUT_FILE"; then state=$(jq -r '.state // empty' "$GH_STDOUT_FILE") oid=$(jq -r '.mergeCommit.oid // empty' "$GH_STDOUT_FILE") + head=$(jq -r '.headRefOid // empty' "$GH_STDOUT_FILE") + if [ "$state" = "MERGED" ] && ! sha_eq "$head" "$EXPECTED"; then + die "merged at head $head, not $EXPECTED; nothing recorded" + fi if [ "$state" = "MERGED" ] && is_sha "$oid"; then PR_MERGE_OID=$oid return 0 diff --git a/tools/pr/pr-session b/tools/pr/pr-session index e6cf6b727..5a03b2a1d 100755 --- a/tools/pr/pr-session +++ b/tools/pr/pr-session @@ -84,11 +84,8 @@ for home in "${HOMES[@]}"; do fi seen_dir=1 while IFS= read -r -d '' file; do - if jq -e 'type == "object"' "$file" >/dev/null 2>/dev/null; then - jq -c --argjson n "$NUMBER" --arg home "$home" --arg file "$file" "$JQ_PROGRAM" "$file" | scrub - else - printf 'pr: skip unreadable %s\n' "$file" >&2 - fi + jq -c --argjson n "$NUMBER" --arg home "$home" --arg file "$file" "$JQ_PROGRAM" "$file" 2>/dev/null \ + | scrub || printf 'pr: skip unreadable %s\n' "$file" >&2 done < <(find "$sessions" -type f -name '*.json' -print0) done [ "$seen_dir" -eq 1 ] || die "no storage/sessions directory" diff --git a/tools/pr/redact-evidence b/tools/pr/redact-evidence index 990660bfa..3e25b4c7d 100755 --- a/tools/pr/redact-evidence +++ b/tools/pr/redact-evidence @@ -89,6 +89,8 @@ done for input in "${FILES[@]}"; do [ -f "$input" ] || die "not a file: $input" + # Keep a leading-dash name from reaching magick/ffmpeg as an option. + case "$input" in -*) input="./$input" ;; esac base=$(basename "$input") dir=$(dirname "$input") ext=$(printf '%s\n' "$base" | tr 'A-Z' 'a-z') diff --git a/tools/pr/test.sh b/tools/pr/test.sh index 2d0849f7e..6bdd4f58d 100755 --- a/tools/pr/test.sh +++ b/tools/pr/test.sh @@ -46,7 +46,7 @@ case "$*" in ;; *"pr view"*) if [ -f "$GH_MERGED" ]; then - printf '{"state":"MERGED","url":"https://github.com/oxwen11/pie/pull/1","headRefOid":"%s","mergeCommit":{"oid":"%s"}}\n' "$FIX_HEAD" "$MERGE_SHA" + printf '{"state":"MERGED","url":"https://github.com/oxwen11/pie/pull/1","headRefOid":"%s","mergeCommit":{"oid":"%s"}}\n' "${FIX_MERGED_HEAD:-$FIX_HEAD}" "$MERGE_SHA" exit 0 fi if [ -f "$GH_UPDATED" ]; then @@ -91,6 +91,10 @@ case "$*" in ;; *"pr merge "*) if [ "${FIX_MERGE_EXIT:-0}" != "0" ]; then + # Race: someone else merges a newer head while ours is refused. + if [ -n "${FIX_MERGED_HEAD:-}" ]; then + touch "$GH_MERGED" + fi echo "merge refused" >&2 exit "$FIX_MERGE_EXIT" fi @@ -141,9 +145,10 @@ reset() { FIX_ASYNC=merged FIX_SYNC_STICKY=0 FIX_MERGE_EXIT=0 + FIX_MERGED_HEAD= GH_LEAK=0 export FIX_HEAD FIX_BASE FIX_STATE FIX_DRAFT FIX_MERGEABLE FIX_MERGE_STATE FIX_REVIEW_JSON - export FIX_BEHIND FIX_CHECKS FIX_CHECKS_EXIT FIX_STACK FIX_ASYNC FIX_SYNC_STICKY FIX_MERGE_EXIT GH_LEAK + export FIX_BEHIND FIX_CHECKS FIX_CHECKS_EXIT FIX_STACK FIX_ASYNC FIX_SYNC_STICKY FIX_MERGE_EXIT FIX_MERGED_HEAD GH_LEAK rm -f "$GH_MERGED" "$GH_UPDATED" : > "$GH_LOG" : > "$MAGICK_LOG" @@ -326,6 +331,47 @@ reset expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$OTHER_SHA" log_not "pr merge " +step merge-conflicting +reset +FIX_MERGEABLE=CONFLICTING +export FIX_MERGEABLE +expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q 'mergeable is CONFLICTING' "$ERR" || fail "missing conflict reason" +log_not "pr merge " + +step merge-checks-failed +reset +FIX_CHECKS='[{"name":"Code check","state":"FAILURE","bucket":"fail","link":"https://example.test/check"}]' +export FIX_CHECKS +expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q 'required checks are not all passing' "$ERR" || fail "missing checks reason" +log_not "pr merge " + +step merge-behind +reset +FIX_BEHIND=1 +export FIX_BEHIND +expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q 'behind base by 1' "$ERR" || fail "missing behind reason" +log_not "pr merge " + +step merge-refused-race +reset +FIX_MERGE_EXIT=1 +FIX_MERGED_HEAD=$OTHER_SHA +export FIX_MERGE_EXIT FIX_MERGED_HEAD +expect_code 2 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +log_not "pr comment" +if printf '%s\n' "$OUT" | grep -q merged; then fail "reported a merge"; fi + +step merge-other-head +reset +FIX_MERGED_HEAD=$OTHER_SHA +export FIX_MERGED_HEAD +expect_code 1 "$PR/pr-merge" 12 --repo oxwen11/pie --sha "$HEAD_SHA" +grep -q "merged at head $OTHER_SHA" "$ERR" || fail "missing head mismatch" +log_not "pr comment" + step merge-refused reset FIX_MERGE_EXIT=1 @@ -385,6 +431,7 @@ cat > "$HOME/.pie/storage/sessions/p3/other.json" < "$HOME/.pie/storage/sessions/p1/bad.json" +printf '%s\n' '{"version":1,"data":{"pullRequests":"oops"}}' > "$HOME/.pie/storage/sessions/p1/odd.json" export HOME unset PIE_HOME || true expect_code 0 "$PR/pr-session" 424 @@ -395,7 +442,8 @@ has_not_token if printf '%s\n' "$OUT" | grep -q '"title":"other"'; then fail "unrelated session matched" fi -grep -q 'skip unreadable' "$ERR" || fail "bad session was not skipped" +grep -q 'skip unreadable .*bad.json' "$ERR" || fail "bad session was not skipped" +grep -q 'skip unreadable .*odd.json' "$ERR" || fail "odd session was not skipped" step session-pie-home PIE_HOME=$TMP/only @@ -455,3 +503,10 @@ expect_code 0 "$PR/redact-evidence" --crop 8x8+1+1 "$TMP/clip.webm" has "$TMP/clip.redacted.webm" grep -q 'libvpx' "$FFMPEG_LOG" || fail "webm codec missing" grep -q 'crop=8:8:1:1' "$FFMPEG_LOG" || fail "ffmpeg crop missing" +: > "$MAGICK_LOG" +mkdir -p "$TMP/dash" +printf 'x\n' > "$TMP/dash/-x.png" +run_in "$TMP/dash" "$PR/redact-evidence" --crop 8x8+0+0 -- -x.png +[ "$CODE" -eq 0 ] || fail "dash file failed" +has "./-x.redacted.png" +grep -q '^\./-x.png ' "$MAGICK_LOG" || fail "dash file reached magick as an option"