Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions ai/bin/sync-pr-review-helpers.sh
Original file line number Diff line number Diff line change
@@ -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 ]]
10 changes: 8 additions & 2 deletions ai/skills/wait-for-pr-reviews/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,7 +33,7 @@ This skill never requests a review from anyone — it only waits for reviews alr
Strip `--check-only` and `--timeout <sec>` 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 "<remaining args>"
scripts/detect-pr.sh "<remaining args>"
```

When nothing remains after stripping, call it with no argument at all — an empty or whitespace-only token reads as an invalid PR argument.
Expand All @@ -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 <repo> <pr_number> --timeout <sec>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
81 changes: 81 additions & 0 deletions ai/skills/wait-for-pr-reviews/scripts/detect-pr.sh
Original file line number Diff line number Diff line change
@@ -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] [<pr-url>|<pr-number>]
#
# Output formats:
# Default (TSV): <owner>\t<repo_name>\t<repo>\t<pr_number>
# --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
}'
73 changes: 73 additions & 0 deletions ai/skills/wait-for-pr-reviews/scripts/git-pr
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Prints the URL of the PR for the current branch, or for an explicit
# <number>/<url> argument (passed straight to `gh pr view` unchanged).
# Usage: git pr [<number>|<url>]
#
# `gh pr view` with no argument resolves the head ref from the local
# branch name, not branch.<name>.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.<name>.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"
114 changes: 114 additions & 0 deletions ai/skills/wait-for-pr-reviews/scripts/lib/github.sh
Original file line number Diff line number Diff line change
@@ -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 <owner/repo> <pr_number>
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}]'
}
Loading
Loading