diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb9db54..aa8d2cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,9 @@ jobs: run: | ai/tests/test-skill-spec.sh ai/tests/test-canonical-skills.sh + ai/bin/sync-pr-review-helpers.sh --check + ai/skills/wait-for-pr-reviews/scripts/tests/test-pending-reviews.sh + ai/skills/wait-for-pr-reviews/scripts/tests/test-portable-skill.sh ai/tests/test-plain-writing-contract.sh ai/tests/test-ai-installers.sh ai/tests/test-command-log.sh diff --git a/ai/README.md b/ai/README.md index acebc1e..7ee6d4a 100644 --- a/ai/README.md +++ b/ai/README.md @@ -24,6 +24,10 @@ The installers preserve regular files and unmanaged symlinks in the destination - `agents/` contains the canonical Markdown agent definitions. Claude consumes them directly and `bin/render-codex-agents.py` converts them to Codex TOML. - `mcp-servers.sh` defines the MCP inventory once while each installer uses its platform's registration command. +## Bundled PR review helpers + +`wait-for-pr-reviews` includes copies of `bin/detect-pr.sh`, `bin/git-pr`, `bin/lib/logging.sh`, and `bin/lib/github.sh` so PR detection and polling work from a copied skill folder. Edit the originals in `bin/`, then run `ai/bin/sync-pr-review-helpers.sh` and commit the refreshed copies. CI runs the script with `--check` to reject missing or stale copies and mismatched executable permissions. The comment-processing passes still need `address-pr-reviews` and its runtime dependencies. + ## Model tiers Skills retain Claude's native `model` field and declare a provider-neutral `metadata.execution-tier`. Codex global instructions route pinned skills through the corresponding custom runner: diff --git a/ai/bin/sync-pr-review-helpers.sh b/ai/bin/sync-pr-review-helpers.sh new file mode 100755 index 0000000..0a22bed --- /dev/null +++ b/ai/bin/sync-pr-review-helpers.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +DESTINATION="$REPO_ROOT/ai/skills/wait-for-pr-reviews/scripts" + +if [[ $# -gt 1 || ($# -eq 1 && "$1" != "--check") ]]; then + echo "Usage: $0 [--check]" >&2 + exit 1 +fi + +mode="${1:-sync}" +helpers=(detect-pr.sh git-pr lib/logging.sh lib/github.sh) +failures=0 +for helper in "${helpers[@]}"; do + source_file="$REPO_ROOT/bin/$helper" + bundled_file="$DESTINATION/$helper" + if [[ "$mode" == "--check" ]]; then + if ! cmp -s "$source_file" "$bundled_file" || + [[ -x "$source_file" && ! -x "$bundled_file" ]] || + [[ ! -x "$source_file" && -x "$bundled_file" ]]; then + echo "Missing or stale bundled helper: $helper. Run ai/bin/sync-pr-review-helpers.sh." >&2 + failures=$((failures + 1)) + fi + else + mkdir -p "$(dirname "$bundled_file")" + cp -p "$source_file" "$bundled_file" + fi +done + +[[ "$failures" -eq 0 ]] diff --git a/ai/skills/wait-for-pr-reviews/SKILL.md b/ai/skills/wait-for-pr-reviews/SKILL.md index 1d8b3bd..a20b0d7 100644 --- a/ai/skills/wait-for-pr-reviews/SKILL.md +++ b/ai/skills/wait-for-pr-reviews/SKILL.md @@ -13,6 +13,12 @@ Some reviews announce themselves before their comments exist: ReviewHog runs a r This skill never requests a review from anyone — it only waits for reviews already in motion. +## Requirements + +Requires Bash 4+, Git, jq, and authenticated `gh` 2.53+. Resolve `scripts/` paths against this skill's directory. PR detection and review polling include their helpers, so they work when only this skill folder is copied into a sandbox. + +Comment processing also requires the installed `address-pr-reviews` skill and its runtime dependencies. Those are separate from the helpers bundled here. + ## Arguments (parsed from user input) - No arguments: detect PR from the current branch @@ -27,7 +33,7 @@ This skill never requests a review from anyone — it only waits for reviews alr Strip `--check-only` and `--timeout ` from the arguments and remember them. Then run the detection script with whatever remains (possibly nothing) — it treats any non-flag token as the PR argument, so the flags must not reach it: ```bash -~/.dotfiles/bin/detect-pr.sh "" +scripts/detect-pr.sh "" ``` When nothing remains after stripping, call it with no argument at all — an empty or whitespace-only token reads as an invalid PR argument. @@ -51,7 +57,7 @@ The file holds `{"pending": [{"reviewer": "…", "signal": "label"|"requested_re ### Step 3: Start the wait in the background -Launch the wait script with the Bash tool's `run_in_background: true` — never foreground; its default timeout exceeds the Bash tool's foreground cap: +Launch the wait script using the harness's background-command support. With Claude's Bash tool, set `run_in_background: true`. With Codex, start it with `exec_command` and a short `yield_time_ms`, then retain the returned session ID. The default timeout is too long for a blocking foreground call: ```bash scripts/wait-for-pending-reviews.sh --timeout diff --git a/ai/skills/wait-for-pr-reviews/scripts/check-pending-reviews.sh b/ai/skills/wait-for-pr-reviews/scripts/check-pending-reviews.sh index 3bab293..14b247f 100755 --- a/ai/skills/wait-for-pr-reviews/scripts/check-pending-reviews.sh +++ b/ai/skills/wait-for-pr-reviews/scripts/check-pending-reviews.sh @@ -35,10 +35,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.dotfiles}" -source "${DOTFILES_DIR}/bin/lib/logging.sh" -# shellcheck source=bin/lib/github.sh -source "${DOTFILES_DIR}/bin/lib/github.sh" +# shellcheck source=lib/logging.sh +source "${SCRIPT_DIR}/lib/logging.sh" +# shellcheck source=lib/github.sh +source "${SCRIPT_DIR}/lib/github.sh" PENDING_JQ="${SCRIPT_DIR}/helpers/pending-reviews.jq" diff --git a/ai/skills/wait-for-pr-reviews/scripts/detect-pr.sh b/ai/skills/wait-for-pr-reviews/scripts/detect-pr.sh new file mode 100755 index 0000000..6d15b2f --- /dev/null +++ b/ai/skills/wait-for-pr-reviews/scripts/detect-pr.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# detect-pr.sh - Detect PR from a URL, number, or current branch +# +# Usage: detect-pr.sh [--json] [|] +# +# Output formats: +# Default (TSV): \t\t\t +# --json: {"pr_number":N,"org":"…","repo":"…","head_branch":"…","head_sha":"…","error":null} +# +# Exit codes: +# TSV mode: 0 on success, 1 on error +# JSON mode: always 0 (errors reported in the "error" field) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/logging.sh +source "${SCRIPT_DIR}/lib/logging.sh" +# shellcheck source=lib/github.sh +source "${SCRIPT_DIR}/lib/github.sh" + +# ── Parse flags ────────────────────────────────────────────────────────────── + +format="tsv" +pr_arg="" +for arg in "$@"; do + case "$arg" in + --json) format="json" ;; + *) pr_arg="$arg" ;; + esac +done + +# ── TSV mode ───────────────────────────────────────────────────────────────── + +if [[ "$format" == "tsv" ]]; then + resolve_pr_target "$pr_arg" + printf '%s\t%s\t%s\t%s\n' "$OWNER" "$REPO_NAME" "$REPO" "$PR_NUMBER" + exit 0 +fi + +# ── JSON mode ──────────────────────────────────────────────────────────────── + +# Require jq for JSON output +if ! command -v jq > /dev/null 2>&1; then + printf '{"error":"Required command not found: jq"}\n' + exit 0 +fi + +json_error() { + jq -n --arg msg "$1" '{"error": $msg}' +} + +# Capture stderr from resolve_pr_target (log_error writes there) +err_file=$(mktemp) +trap 'rm -f "$err_file"' EXIT + +if ! resolve_pr_target "$pr_arg" 2>"$err_file"; then + # Strip ANSI color codes and [ERROR] prefix, join lines into one message + err=$(sed $'s/\x1b\\[[0-9;]*m//g; s/^\\[ERROR\\] //' "$err_file" | paste -sd ' ' -) + json_error "${err:-Failed to resolve PR target}" + exit 0 +fi + +# Fetch head branch and SHA +pr_json=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName,headRefOid 2>/dev/null) || { + json_error "Could not fetch PR #${PR_NUMBER} from ${REPO}" + exit 0 +} + +echo "$pr_json" | jq \ + --argjson pr_number "$PR_NUMBER" \ + --arg org "${OWNER,,}" \ + --arg repo "${REPO_NAME,,}" \ + '{ + pr_number: $pr_number, + org: $org, + repo: $repo, + head_branch: .headRefName, + head_sha: .headRefOid, + error: null + }' diff --git a/ai/skills/wait-for-pr-reviews/scripts/git-pr b/ai/skills/wait-for-pr-reviews/scripts/git-pr new file mode 100755 index 0000000..44fc494 --- /dev/null +++ b/ai/skills/wait-for-pr-reviews/scripts/git-pr @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Prints the URL of the PR for the current branch, or for an explicit +# / argument (passed straight to `gh pr view` unchanged). +# Usage: git pr [|] +# +# `gh pr view` with no argument resolves the head ref from the local +# branch name, not branch..merge, so it misses a fork PR checked out +# under a different local name (e.g. via `gh pr checkout`). +# This resolves the real branch name instead, then looks it up with +# `gh pr list --head`, which matches bare names across every fork, so a +# same-named branch in an unrelated fork (e.g. "main", "patch-1") can +# outrank the right one. +# The push remote's owner narrows that match back down to the right fork. + +set -e + +pr_url() { env GH_PAGER= gh pr view "$@" --json url -q .url; } + +if [ -n "$1" ]; then + pr_url "$1" + exit +fi + +current_branch=$(git branch --show-current) +branch="" +remote="" +if [ -n "$current_branch" ]; then + branch=$(git config "branch.$current_branch.merge" 2>/dev/null || true) + # Mirrors gh's own push-remote resolution order. + remote=$(git config "branch.$current_branch.pushRemote" 2>/dev/null || true) + [ -n "$remote" ] || remote=$(git config remote.pushDefault 2>/dev/null || true) + [ -n "$remote" ] || remote=$(git config "branch.$current_branch.remote" 2>/dev/null || true) + # "." means the branch tracks a local branch, not a remote one. + [ "$remote" = "." ] && { remote=""; branch=""; } + [ -n "$remote" ] || remote=origin +fi + +case "$branch" in + refs/pull/*/head) + # gh's own no-argument lookup already resolves this merge-ref form. + pr_url + exit + ;; +esac + +branch=${branch#refs/heads/} +[ -n "$branch" ] || branch=$current_branch + +if [ -z "$branch" ]; then + pr_url + exit +fi + +# gh pr checkout writes the fork's URL, not a remote name, into +# branch..remote when the fork has no configured remote. +remote_url=$(git remote get-url "$remote" 2>/dev/null || echo "$remote") +remote_url=${remote_url%.git} +remote_url=${remote_url%/} +owner=${remote_url%/*} +owner=${owner##*[:/]} +owner=${owner,,} + +# Prefers an OPEN PR over a merged one when both exist for the branch. +url=$(env GIT_PR_OWNER="$owner" GH_PAGER= gh pr list --head "$branch" --state all --limit 100 \ + --json url,state,headRepositoryOwner \ + -q '[.[] | select(((.headRepositoryOwner.login // "") | ascii_downcase) == $ENV.GIT_PR_OWNER)] | sort_by(.state != "OPEN") | .[0].url') + +if [ -z "$url" ] || [ "$url" = "null" ]; then + echo "no pull requests found for branch '$branch' owned by '$owner'" >&2 + exit 1 +fi + +echo "$url" diff --git a/ai/skills/wait-for-pr-reviews/scripts/lib/github.sh b/ai/skills/wait-for-pr-reviews/scripts/lib/github.sh new file mode 100644 index 0000000..2ce7cda --- /dev/null +++ b/ai/skills/wait-for-pr-reviews/scripts/lib/github.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# github.sh - Shared GitHub helpers +# +# Source this file to get GitHub helpers: +# source "${SCRIPT_DIR}/lib/github.sh" +# +# Functions: +# get_github_user - Print the authenticated GitHub username, or exit +# parse_pr_url - Parse a GitHub PR URL into OWNER, REPO_NAME, REPO, PR_NUMBER +# get_current_repo - Get the current repo as owner/name +# resolve_pr_target - Resolve a PR argument (URL, number, or branch) into OWNER, REPO_NAME, REPO, PR_NUMBER +# get_requested_reviewers - Requested reviewers on a PR as [{login, type}] + +get_github_user() { + gh api user --jq '.login' 2>/dev/null || { + log_error "Could not determine GitHub username. Are you logged in with 'gh auth login'?" + exit 1 + } +} + +# Parse a GitHub PR URL into OWNER, REPO_NAME, REPO, and PR_NUMBER. +# Returns 0 on success, 1 if the string is not a valid PR URL. +# shellcheck disable=SC2034 # Variables are intentionally set for the caller +parse_pr_url() { + local url="$1" + if [[ "$url" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then + OWNER="${BASH_REMATCH[1]}" + REPO_NAME="${BASH_REMATCH[2]}" + REPO="${OWNER}/${REPO_NAME}" + PR_NUMBER="${BASH_REMATCH[3]}" + return 0 + fi + return 1 +} + +# Get the current repository as owner/name. +# shellcheck disable=SC2034 # Variables are intentionally set for the caller +get_current_repo() { + gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null || { + log_error "Could not determine repository. Run from inside a repo or pass a full PR URL." + exit 1 + } +} + +# Resolve a PR argument into OWNER, REPO_NAME, REPO, and PR_NUMBER. +# Accepts a URL, numeric PR number, or empty string (infers from current branch). +# Sets SKIP_REPO_VALIDATION=true when the repo is inferred from the working +# directory (bare number or auto-detect) to avoid a redundant gh call. +# Returns 0 on success, 1 on failure. +# shellcheck disable=SC2034 # Variables are intentionally set for the caller +SKIP_REPO_VALIDATION=false +resolve_pr_target() { + local pr_arg="${1:-}" + SKIP_REPO_VALIDATION=false + if [[ -z "$pr_arg" ]]; then + local pr_url + pr_url=$("$(dirname "${BASH_SOURCE[0]}")/../git-pr" 2>/dev/null) || { + log_error "No PR found for the current branch. Specify a PR number or URL." + return 1 + } + if ! parse_pr_url "$pr_url"; then + log_error "Could not parse PR URL from current branch: ${pr_url}" + return 1 + fi + SKIP_REPO_VALIDATION=true + elif parse_pr_url "$pr_arg"; then + : + elif [[ "$pr_arg" =~ ^[0-9]+$ ]]; then + PR_NUMBER="$pr_arg" + REPO=$(get_current_repo) || return 1 + OWNER="${REPO%%/*}" + REPO_NAME="${REPO##*/}" + SKIP_REPO_VALIDATION=true + else + log_error "Invalid PR argument: ${pr_arg}" + log_error "Expected a PR number or URL (https://github.com/owner/repo/pull/123)." + return 1 + fi +} + +# Requested reviewers on a PR, as [{login, type}] where type is GraphQL's +# __typename ("User" or "Bot"). GitHub clears a request when that reviewer +# submits, so anything still listed is mid-review. +# +# GraphQL, not REST: `pulls/:n/requested_reviewers` returns only `.users`, and a +# GitHub App reviewer (Copilot, Greptile) is a Bot node that never appears there, +# so the REST list reads empty while the app is mid-review. Team and Mannequin +# nodes drop out here, which is what excludes teams: a team request lingers until +# a human member reviews, which a bot's review never satisfies. +# +# Usage: get_requested_reviewers +get_requested_reviewers() { + local slug="$1" pr="$2" + + # shellcheck disable=SC2016 # $owner/$name/$pr are GraphQL variables, not shell + local query='query($owner: String!, $name: String!, $pr: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $pr) { + reviewRequests(first: 100) { + nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } } } + } + } + } + }' + + gh api graphql \ + -f query="$query" \ + -f owner="${slug%%/*}" \ + -f name="${slug##*/}" \ + -F pr="$pr" \ + --jq '[.data.repository.pullRequest.reviewRequests.nodes[]?.requestedReviewer + | select(. != null and (.__typename == "User" or .__typename == "Bot")) + | {login: (.login // ""), type: .__typename}]' +} diff --git a/ai/skills/wait-for-pr-reviews/scripts/lib/logging.sh b/ai/skills/wait-for-pr-reviews/scripts/lib/logging.sh new file mode 100644 index 0000000..f5406ab --- /dev/null +++ b/ai/skills/wait-for-pr-reviews/scripts/lib/logging.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# logging.sh - Shared logging utilities for bash scripts +# +# Source this file to get colored logging functions: +# source "${SCRIPT_DIR}/lib/logging.sh" +# +# Functions: +# log_info - Blue [INFO] prefix +# log_success - Green [SUCCESS] prefix +# log_warn - Yellow [WARN] prefix +# log_error - Red [ERROR] prefix (outputs to stderr) +# log_section - Prints a titled section divider + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +DIM='\033[2m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +log_section() { + echo "" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_info "$1" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +# ── Heartbeat ────────────────────────────────────────────────────────────── + +# Global PID for the heartbeat background process +_HEARTBEAT_PID="" + +# Start a background heartbeat that prints elapsed time to stderr every N seconds. +# IMPORTANT: Must be called from the main shell, not from a subshell or pipeline. +# If called inside $(...) or a pipe, _HEARTBEAT_PID won't propagate and the +# process will leak. +# Usage: start_heartbeat [interval_seconds] [label] +start_heartbeat() { + local interval="${1:-30}" + local label="${2:-Working}" + stop_heartbeat + ( + exec >/dev/null # close stdout so we don't hold pipes open + trap 'exit 0' TERM + local start_time=$SECONDS + while true; do + # Background sleep + wait so TERM interrupts immediately + sleep "$interval" & + wait $! 2>/dev/null || exit 0 + local elapsed=$((SECONDS - start_time)) + local mins=$((elapsed / 60)) + local secs=$((elapsed % 60)) + printf '%b[HEARTBEAT] %s… %dm %02ds elapsed%b\n' "$DIM" "$label" "$mins" "$secs" "$NC" >&2 + done + ) & + _HEARTBEAT_PID=$! +} + +# Stop the heartbeat background process. Safe to call when none is running. +stop_heartbeat() { + if [[ -n "${_HEARTBEAT_PID:-}" ]]; then + kill "$_HEARTBEAT_PID" 2>/dev/null || true + wait "$_HEARTBEAT_PID" 2>/dev/null || true + _HEARTBEAT_PID="" + fi +} diff --git a/ai/skills/wait-for-pr-reviews/scripts/tests/test-portable-skill.sh b/ai/skills/wait-for-pr-reviews/scripts/tests/test-portable-skill.sh new file mode 100755 index 0000000..503acc7 --- /dev/null +++ b/ai/skills/wait-for-pr-reviews/scripts/tests/test-portable-skill.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sandbox=$(mktemp -d) +trap 'rm -rf "$sandbox"' EXIT +mkdir -p "$sandbox/bin" "$sandbox/home" "$sandbox/unrelated" +cp -R "$SCRIPT_DIR/../.." "$sandbox/skill" + +cat >"$sandbox/bin/git" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +case "$*" in + 'branch --show-current') echo local-review ;; + 'config branch.local-review.merge') echo refs/heads/contributor-feature ;; + 'config branch.local-review.pushRemote') echo contributor ;; + 'remote get-url contributor') echo git@github.com:Contributor/posthog.git ;; + *) echo "Unexpected git arguments: $*" >&2; exit 1 ;; +esac +MOCK + +cat >"$sandbox/bin/gh" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$MOCK_CALLS" +projection='.' +for ((i = 1; i <= $#; i++)); do + if [[ "${!i}" == '--jq' || "${!i}" == '-q' ]]; then + next=$((i + 1)) + projection="${!next}" + fi +done + +if [[ "$1 $2" == 'repo view' ]]; then + echo '{"nameWithOwner":"PostHog/posthog"}' | jq -r "$projection" +elif [[ "$1 $2" == 'pr list' ]]; then + [[ "$*" == *'--head contributor-feature'* && "${GIT_PR_OWNER:-}" == contributor ]] + echo '[{"url":"https://github.com/PostHog/posthog/pull/1","state":"OPEN","headRepositoryOwner":{"login":"unrelated"}},{"url":"https://github.com/PostHog/posthog/pull/98865","state":"OPEN","headRepositoryOwner":{"login":"Contributor"}}]' | jq -r "$projection" +elif [[ "$1 $2" == 'api graphql' ]]; then + [[ "$*" == *'owner=PostHog'* && "$*" == *'name=posthog'* && "$*" == *'pr=98865'* ]] + if [[ "$MOCK_STATE" == clear ]]; then + echo '{"data":{"repository":{"pullRequest":{"reviewRequests":{"nodes":[{"requestedReviewer":{"__typename":"Team","name":"reviewers"}},{"requestedReviewer":{"__typename":"User","login":"human"}}]}}}}}' | jq -r "$projection" + else + echo '{"data":{"repository":{"pullRequest":{"reviewRequests":{"nodes":[{"requestedReviewer":{"__typename":"Bot","login":"greptile-apps[bot]"}},{"requestedReviewer":{"__typename":"User","login":"copilot-pull-request-reviewer[bot]"}},{"requestedReviewer":{"__typename":"User","login":"human"}},{"requestedReviewer":{"__typename":"Team","name":"reviewers"}},{"requestedReviewer":{"__typename":"Mannequin","login":"former-user"}},{"requestedReviewer":null}]}}}}}' | jq -r "$projection" + fi +elif [[ "$1" == api && "$*" == *'repos/PostHog/posthog/'* ]]; then + case "$*" in + *'/timeline?per_page=100'*) + echo '[[{"event":"labeled","label":{"name":"reviewhog"},"created_at":"2026-09-15T10:00:00Z"}]]' + ;; + *'/reviews?per_page=100'*|*'/comments?per_page=100'*) echo '[[]]' ;; + *'repos/PostHog/posthog/issues/98865 --jq'*) + [[ "$MOCK_STATE" != failure ]] || exit 1 + if [[ "$MOCK_STATE" == clear ]]; then + echo '{"labels":[]}' | jq -r "$projection" + else + echo '{"labels":[{"name":"reviewhog"}]}' | jq -r "$projection" + fi + ;; + *) echo "Unexpected API arguments: $*" >&2; exit 1 ;; + esac +else + echo "Unexpected gh arguments: $*" >&2 + exit 1 +fi +MOCK +chmod +x "$sandbox/bin/git" "$sandbox/bin/gh" + +passes=0 +failures=0 + +assert_run() { + local description="$1" mock_state="$2" expected_status="$3" jq_filter="$4" expected="$5" + shift 5 + local status=0 actual + : >"$sandbox/calls" + env -i HOME="$sandbox/home" DOTFILES_DIR="$sandbox/missing" PATH="$sandbox/bin:$PATH" \ + MOCK_STATE="$mock_state" MOCK_CALLS="$sandbox/calls" \ + "$@" >"$sandbox/stdout" 2>"$sandbox/stderr" || status=$? + actual=$(<"$sandbox/stdout") + if [[ -n "$jq_filter" ]]; then + actual=$(jq -cr "$jq_filter" "$sandbox/stdout") || actual='invalid JSON' + fi + if [[ "$status" == "$expected_status" && "$actual" == "$expected" ]]; then + passes=$((passes + 1)) + else + echo "FAIL: $description" + echo " expected exit $expected_status and '$expected'; got exit $status and '$actual'" + cat "$sandbox/stderr" + failures=$((failures + 1)) + fi +} + +cd "$sandbox/unrelated" +scripts="$sandbox/skill/scripts" +target=$'PostHog\tposthog\tPostHog/posthog\t98865' + +assert_run 'detect a PR URL from the copied skill' pending 0 '' "$target" \ + "$scripts/detect-pr.sh" https://github.com/PostHog/posthog/pull/98865 +assert_run 'detect a PR number from the copied skill' pending 0 '' "$target" \ + "$scripts/detect-pr.sh" 98865 +assert_run 'detect the renamed local branch in the correct fork' pending 0 '' "$target" \ + "$scripts/detect-pr.sh" + +# shellcheck disable=SC2016 +assert_run 'project GraphQL User and Bot reviewers and exclude teams' pending 0 '.' \ + '[{"login":"greptile-apps[bot]","type":"Bot"},{"login":"copilot-pull-request-reviewer[bot]","type":"User"},{"login":"human","type":"User"}]' \ + bash -c 'source "$1/lib/github.sh"; get_requested_reviewers PostHog/posthog 98865' bash "$scripts" + +pending_filter='{pending: (.pending | sort_by(.reviewer)), warnings}' +pending_verdict='{"pending":[{"reviewer":"copilot-pull-request-reviewer[bot]","signal":"requested_reviewer","since":null},{"reviewer":"greptile-apps[bot]","signal":"requested_reviewer","since":null},{"reviewer":"reviewhog","signal":"label","since":"2026-09-15T10:00:00Z"}],"warnings":[]}' +assert_run 'compute pending reviews using the bundled libraries' pending 0 "$pending_filter" "$pending_verdict" \ + "$scripts/check-pending-reviews.sh" PostHog/posthog 98865 +assert_run 'exit immediately when reviews have cleared' clear 0 '.' '{"pending":[],"warnings":[]}' \ + "$scripts/wait-for-pending-reviews.sh" PostHog/posthog 98865 --interval 0 --timeout 0 +assert_run 'preserve the pending verdict on timeout' pending 2 "$pending_filter" "$pending_verdict" \ + "$scripts/wait-for-pending-reviews.sh" PostHog/posthog 98865 --interval 0 --timeout 0 +assert_run 'fail after repeated fetch failures' failure 1 '' '' \ + "$scripts/wait-for-pending-reviews.sh" PostHog/posthog 98865 --interval 0 --timeout 30 + +failed_fetch_attempts=$(wc -l <"$sandbox/calls" | tr -d ' ') +if [[ "$failed_fetch_attempts" == 3 ]]; then + passes=$((passes + 1)) +else + echo "FAIL: retry failed fetches exactly three times (got $failed_fetch_attempts)" + failures=$((failures + 1)) +fi + +echo "$passes passed, $failures failed" +[[ "$failures" -eq 0 ]] diff --git a/ai/skills/wait-for-pr-reviews/scripts/wait-for-pending-reviews.sh b/ai/skills/wait-for-pr-reviews/scripts/wait-for-pending-reviews.sh index ea56c00..545edf3 100755 --- a/ai/skills/wait-for-pr-reviews/scripts/wait-for-pending-reviews.sh +++ b/ai/skills/wait-for-pr-reviews/scripts/wait-for-pending-reviews.sh @@ -21,8 +21,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.dotfiles}" -source "${DOTFILES_DIR}/bin/lib/logging.sh" +# shellcheck source=lib/logging.sh +source "${SCRIPT_DIR}/lib/logging.sh" CHECK_SCRIPT="${SCRIPT_DIR}/check-pending-reviews.sh"