diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 02400a35..672e9368 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -101,6 +101,25 @@ jobs: advanced-security: false inputs: .github/workflows + codex-comments: + name: Codex Comments + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check unresolved Codex comments + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/check_codex_comments.sh ${{ github.event.pull_request.number }} + publish-main: name: Publish GHCR :main needs: [test, lint, lint-actions] diff --git a/.mux/skills/pull-requests/SKILL.md b/.mux/skills/pull-requests/SKILL.md new file mode 100644 index 00000000..7ecae7a3 --- /dev/null +++ b/.mux/skills/pull-requests/SKILL.md @@ -0,0 +1,129 @@ +--- +name: pull-requests +description: Guidelines for creating and managing Pull Requests in this repo +--- + +# Pull Request Guidelines + +## Attribution Footer + +Public work (issues/PRs/commits) must use πŸ€– in the title and include this footer in the body: + +```md +--- + +_Generated with `mux` β€’ Model: `` β€’ Thinking: `` β€’ Cost: `$`_ + + +``` + +Always check `$MUX_MODEL_STRING`, `$MUX_THINKING_LEVEL`, and `$MUX_COSTS_USD` via bash before creating or updating PRsβ€”include them in the footer if set. + +## Lifecycle Rules + +- Before submitting a PR, ensure the branch name reflects the work and the base branch is correct. + - PRs are always squash-merged into `main`. + - Often, work begins from another PR's merged state; rebase onto `main` before submitting a new PR. +- Reuse existing PRs; never close or recreate without instruction. +- Force-push minor PR updates; otherwise add a new commit to preserve the change timeline. +- If a PR is already open for your change, keep it up to date with the latest commits; don't leave it stale. +- Never enable auto-merge or merge into `main` yourself. The user must explicitly merge PRs. + +## CI & Validation + +- After pushing, you may use `./scripts/wait_pr_checks.sh ` to wait for CI to pass. +- Use `wait_pr_checks` only when there's no more useful work to do. +- Waiting for PR checks can take 10+ minutes, so prefer local validation first (for this repo: `make verify-vendor`, `make test`, `make build`) to catch issues early. +- If asked to fix an issue in CI, first replicate it locally, get it to pass locally, then use `wait_pr_checks` to wait for CI to pass. + +## Status Decoding + +| Field | Value | Meaning | +| ------------------ | ------------- | ------------------- | +| `mergeable` | `MERGEABLE` | Clean, no conflicts | +| `mergeable` | `CONFLICTING` | Needs resolution | +| `mergeStateStatus` | `CLEAN` | Ready to merge | +| `mergeStateStatus` | `BLOCKED` | Waiting for CI | +| `mergeStateStatus` | `BEHIND` | Needs rebase | +| `mergeStateStatus` | `DIRTY` | Has conflicts | + +If behind: `git fetch origin && git rebase origin/main && git push --force-with-lease`. + +## Codex Review Workflow + +When posting multi-line comments with `gh` (e.g., `@codex review`), **do not** rely on `\n` escapes inside quoted `--body` strings (they will be sent as literal text). Prefer `--body-file -` with a heredoc to preserve real newlines: + +```bash +gh pr comment --body-file - <<'EOF' +@codex review + + +EOF +``` + +### Handling Codex Comments + +Use these scripts to check, resolve, and wait on Codex review comments: + +- `./scripts/check_codex_comments.sh ` β€” Lists unresolved Codex comments (both regular comments and review threads). Outputs thread IDs needed for resolution. +- `./scripts/resolve_pr_comment.sh ` β€” Resolves a review thread by its ID (e.g., `PRRT_abc123`). +- `./scripts/wait_pr_codex.sh ` β€” Waits for Codex to respond to the latest `@codex review` request. When the PR looks good, Codex leaves an explicit approval comment (e.g., it will say `Didn't find any major issues`). + +- `./scripts/wait_pr_ready.sh ` β€” Convenience wrapper that runs Codex waiting first and CI checks second. Use it when you are done coding and want to block until the PR is ready or actionable feedback appears. + +When Codex leaves review comments, you **must** address them before the PR can merge: + +1. Push your fixes +2. Resolve each review thread: `./scripts/resolve_pr_comment.sh ` +3. Comment `@codex review` to re-request review +4. Run `./scripts/wait_pr_codex.sh ` to wait for the next Codex response (either new comments to address, or an explicit approval comment) + +### Required Loop Discipline + +After a PR is open, stay in a review loop until completion: + +1. Run local validation and push fixes. +2. Request review with `@codex review`. +3. Run `./scripts/wait_pr_codex.sh ` and wait for Codex to respond. +4. If Codex leaves comments, address them, resolve each thread, push, and repeat from step 2. +5. Once Codex explicitly approves, run `./scripts/wait_pr_checks.sh ` and wait for required checks. + +Only stop the loop early if a reviewer is clearly misunderstanding the change intent and further edits would be counterproductive. In that case, leave a clarifying PR comment and pause for human direction. + +## PR Title Conventions + +- Title prefixes: `perf|refactor|fix|feat|ci|tests|bench` +- Example: `πŸ€– fix: handle workspace rename edge cases` +- Use `tests:` for test-only changes (test helpers, flaky test fixes, storybook) +- Use `ci:` for CI config changes + +## PR Bodies + +### Structure + +PR bodies should generally follow this structure; omit sections that are N/A or trivially inferable for the change. + +- Summary + - Single-paragraph executive summary of the change +- Background + - The "why" behind the change + - What problem this solves + - Relevant commits, issues, or PRs that capture more context +- Implementation +- Validation + - Steps taken to prove the change works as intended + - Avoid boilerplate like `ran tests`; include this section only for novel, change-specific steps + - Do not include steps implied by passing PR checks +- Risks + - PRs that touch intricate logic must include an assessment of regression risk + - Explain regression risk in terms of severity and affected product areas + +## Upkeep + +Once the code is pushed to the remote (even if not yet a Pull Request), do your best to commit +and push all changes before responding to ensure its visible to the user. Commits on the working branch +are for yourself to understand the change, they do not have to follow repository conventions as the +PR body and title become the commit subject and body respectively. + +Whenever generating a compaction summary, include whether or not a Pull Request was opened +and the general state of the remote (e.g. CI checks, known reviews, divergence). diff --git a/AGENTS.md b/AGENTS.md index 7079c249..165eaf9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,9 +88,20 @@ Run from repository root. ### Pull request descriptions - Include: what changed, why, validation commands run, and any follow-up work. -- For public mux-generated PRs/commits in this environment, include footer: +- For public mux-generated PRs/commits in this environment, include the attribution footer defined in `.mux/skills/pull-requests/SKILL.md`. -```md ---- -_Generated with [`mux`](https://github.com/coder/mux) β€’ Model: `` β€’ Thinking: ``_ -``` +## PR Workflow (Codex) + +- Before creating or updating any PR, commit, or public issue, read `.mux/skills/pull-requests/SKILL.md` and follow it. +- Use `./scripts/wait_pr_ready.sh ` for a one-command wait flow after requesting review. +- Prefer `gh` CLI for GitHub interactions over manual web/curl flows. + +When a PR exists, stay in this loop until ready: +1. Push your latest fixes. +2. Run local validation (`make verify-vendor`, `make test`, `make build`). +3. Request review with `@codex review`. +4. Run `./scripts/wait_pr_codex.sh ` and wait for Codex. +5. If Codex leaves comments, address them, resolve threads with `./scripts/resolve_pr_comment.sh `, push, and repeat. +6. After explicit Codex approval, run `./scripts/wait_pr_checks.sh `. + +Only stop the loop early if the reviewer is clearly misunderstanding the intended change and further churn would be counterproductive. In that case, leave a clarifying PR comment and wait for human direction. diff --git a/scripts/check_codex_comments.sh b/scripts/check_codex_comments.sh new file mode 100755 index 00000000..d4c9a342 --- /dev/null +++ b/scripts/check_codex_comments.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +PR_NUMBER=$1 +BOT_LOGIN_GRAPHQL="chatgpt-codex-connector" + +if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "❌ PR number must be numeric. Got: '$PR_NUMBER'" + exit 1 +fi + +echo "Checking for unresolved Codex comments in PR #${PR_NUMBER}..." + +REPO_INFO=$(gh repo view --json owner,name --jq '{owner: .owner.login, name: .name}') +OWNER=$(echo "$REPO_INFO" | jq -r '.owner') +REPO=$(echo "$REPO_INFO" | jq -r '.name') + +# Depot runners sometimes hit transient network timeouts to api.github.com. +# Retry the GraphQL request a few times before failing the required check. +MAX_ATTEMPTS=5 +BACKOFF_SECS=2 + +# shellcheck disable=SC2016 # Single quotes are intentional - these are GraphQL queries. +COMMENTS_QUERY='query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + comments(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { login } + body + createdAt + isMinimized + } + } + } + } +}' + +# shellcheck disable=SC2016 # Single quotes are intentional - these are GraphQL queries. +THREADS_QUERY='query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + comments(first: 1) { + nodes { + id + author { login } + body + createdAt + path + line + } + } + } + } + } + } +}' + +fetch_graphql_with_retry() { + local query="$1" + shift + + local attempt + local backoff + backoff="$BACKOFF_SECS" + + for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + if gh api graphql \ + -f query="$query" \ + -F owner="$OWNER" \ + -F repo="$REPO" \ + -F pr="$PR_NUMBER" \ + "$@"; then + return 0 + fi + + if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then + echo "❌ GraphQL query failed after ${MAX_ATTEMPTS} attempts" + return 1 + fi + + echo "⚠️ GraphQL query failed (attempt ${attempt}/${MAX_ATTEMPTS}); retrying in ${backoff}s..." + sleep "$backoff" + backoff=$((backoff * 2)) + done +} + +COMMENTS_CURSOR="" +ALL_COMMENTS='[]' + +while true; do + if [ -n "$COMMENTS_CURSOR" ]; then + COMMENTS_RESULT=$(fetch_graphql_with_retry "$COMMENTS_QUERY" -F cursor="$COMMENTS_CURSOR") + else + COMMENTS_RESULT=$(fetch_graphql_with_retry "$COMMENTS_QUERY") + fi + + if [ "$(echo "$COMMENTS_RESULT" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then + echo "❌ PR #${PR_NUMBER} does not exist in ${OWNER}/${REPO}." + exit 1 + fi + + PAGE_COMMENTS=$(echo "$COMMENTS_RESULT" | jq '.data.repository.pullRequest.comments.nodes') + ALL_COMMENTS=$(jq -cn --argjson all "$ALL_COMMENTS" --argjson page "$PAGE_COMMENTS" '$all + $page') + + HAS_NEXT=$(echo "$COMMENTS_RESULT" | jq -r '.data.repository.pullRequest.comments.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + + COMMENTS_CURSOR=$(echo "$COMMENTS_RESULT" | jq -r '.data.repository.pullRequest.comments.pageInfo.endCursor') + if [ -z "$COMMENTS_CURSOR" ] || [ "$COMMENTS_CURSOR" = "null" ]; then + echo "❌ Assertion failed: comments pagination cursor missing while hasNextPage=true" + exit 1 + fi +done + +THREADS_CURSOR="" +ALL_THREADS='[]' + +while true; do + if [ -n "$THREADS_CURSOR" ]; then + THREADS_RESULT=$(fetch_graphql_with_retry "$THREADS_QUERY" -F cursor="$THREADS_CURSOR") + else + THREADS_RESULT=$(fetch_graphql_with_retry "$THREADS_QUERY") + fi + + if [ "$(echo "$THREADS_RESULT" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then + echo "❌ PR #${PR_NUMBER} does not exist in ${OWNER}/${REPO}." + exit 1 + fi + + PAGE_THREADS=$(echo "$THREADS_RESULT" | jq '.data.repository.pullRequest.reviewThreads.nodes') + ALL_THREADS=$(jq -cn --argjson all "$ALL_THREADS" --argjson page "$PAGE_THREADS" '$all + $page') + + HAS_NEXT=$(echo "$THREADS_RESULT" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + + THREADS_CURSOR=$(echo "$THREADS_RESULT" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor') + if [ -z "$THREADS_CURSOR" ] || [ "$THREADS_CURSOR" = "null" ]; then + echo "❌ Assertion failed: review thread pagination cursor missing while hasNextPage=true" + exit 1 + fi +done + +# Filter regular comments from bot that aren't minimized, excluding: +# - "Didn't find any major issues" (no issues found) +# - "usage limits have been reached" (rate limit error, not a real review) +REGULAR_COMMENTS=$(echo "$ALL_COMMENTS" | jq "[.[] | select(.author.login == \"${BOT_LOGIN_GRAPHQL}\" and .isMinimized == false and (.body | test(\"Didn't find any major issues|usage limits have been reached\") | not))]") +REGULAR_COUNT=$(echo "$REGULAR_COMMENTS" | jq 'length') + +# Filter unresolved review threads from bot +UNRESOLVED_THREADS=$(echo "$ALL_THREADS" | jq "[.[] | select(.isResolved == false and .comments.nodes[0].author.login == \"${BOT_LOGIN_GRAPHQL}\")]") +UNRESOLVED_COUNT=$(echo "$UNRESOLVED_THREADS" | jq 'length') + +TOTAL_UNRESOLVED=$((REGULAR_COUNT + UNRESOLVED_COUNT)) + +echo "Found ${REGULAR_COUNT} unminimized regular comment(s) from bot" +echo "Found ${UNRESOLVED_COUNT} unresolved review thread(s) from bot" + +if [ "$TOTAL_UNRESOLVED" -gt 0 ]; then + echo "" + echo "❌ Found ${TOTAL_UNRESOLVED} unresolved comment(s) from Codex in PR #${PR_NUMBER}" + echo "" + echo "Codex comments:" + + if [ "$REGULAR_COUNT" -gt 0 ]; then + echo "$REGULAR_COMMENTS" | jq -r '.[] | " - [\(.createdAt)]\n\(.body)\n"' + fi + + if [ "$UNRESOLVED_COUNT" -gt 0 ]; then + echo "$UNRESOLVED_THREADS" | jq -r '.[] | " - [\(.comments.nodes[0].createdAt)] thread=\(.id) \(.comments.nodes[0].path // "comment"):\(.comments.nodes[0].line // "")\n\(.comments.nodes[0].body)\n"' + echo "" + echo "Resolve review threads with: ./scripts/resolve_pr_comment.sh " + fi + + echo "" + echo "Please address or resolve all Codex comments before merging." + exit 1 +fi + +echo "βœ… No unresolved Codex comments found" +exit 0 diff --git a/scripts/check_pr_reviews.sh b/scripts/check_pr_reviews.sh new file mode 100755 index 00000000..49df5fb9 --- /dev/null +++ b/scripts/check_pr_reviews.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Check for unresolved PR review comments. +# Usage: ./scripts/check_pr_reviews.sh +# Exits 0 if all resolved, 1 if unresolved comments exist. + +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +PR_NUMBER="$1" +if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "❌ PR number must be numeric. Got: '$PR_NUMBER'" + exit 1 +fi + +REPO_INFO=$(gh repo view --json owner,name --jq '{owner: .owner.login, name: .name}') +OWNER=$(echo "$REPO_INFO" | jq -r '.owner') +REPO=$(echo "$REPO_INFO" | jq -r '.name') + +# shellcheck disable=SC2016 # Single quotes are intentional - this is a GraphQL query. +GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + comments(first: 1) { + nodes { + author { login } + body + diffHunk + commit { oid } + } + } + } + } + } + } +}' + +THREAD_CURSOR="" +ALL_THREADS='[]' + +while true; do + if [ -n "$THREAD_CURSOR" ]; then + RESULT=$(gh api graphql \ + -f query="$GRAPHQL_QUERY" \ + -F owner="$OWNER" \ + -F repo="$REPO" \ + -F pr="$PR_NUMBER" \ + -F cursor="$THREAD_CURSOR") + else + RESULT=$(gh api graphql \ + -f query="$GRAPHQL_QUERY" \ + -F owner="$OWNER" \ + -F repo="$REPO" \ + -F pr="$PR_NUMBER") + fi + + if [ "$(echo "$RESULT" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then + echo "❌ PR #$PR_NUMBER was not found in ${OWNER}/${REPO}." + exit 1 + fi + + PAGE_THREADS=$(echo "$RESULT" | jq '.data.repository.pullRequest.reviewThreads.nodes') + ALL_THREADS=$(jq -cn --argjson all "$ALL_THREADS" --argjson page "$PAGE_THREADS" '$all + $page') + + HAS_NEXT=$(echo "$RESULT" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + + THREAD_CURSOR=$(echo "$RESULT" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor') + if [ -z "$THREAD_CURSOR" ] || [ "$THREAD_CURSOR" = "null" ]; then + echo "❌ Assertion failed: review thread cursor missing while hasNextPage=true" + exit 1 + fi +done + +UNRESOLVED=$(echo "$ALL_THREADS" | jq -c '.[] | select(.isResolved == false) | {thread_id: .id, user: (.comments.nodes[0].author.login // "unknown"), body: (.comments.nodes[0].body // ""), diff_hunk: (.comments.nodes[0].diffHunk // ""), commit_id: (.comments.nodes[0].commit.oid // "")}') + +if [ -n "$UNRESOLVED" ]; then + echo "❌ Unresolved review comments found:" + echo "$UNRESOLVED" | jq -r '" \(.user): \(.body)"' + echo "" + echo "To resolve a comment thread, use:" + echo "$UNRESOLVED" | jq -r '" ./scripts/resolve_pr_comment.sh \(.thread_id)"' + echo "" + echo "View PR: https://github.com/${OWNER}/${REPO}/pull/$PR_NUMBER" + exit 1 +fi + +echo "βœ… All review comments resolved" +exit 0 diff --git a/scripts/extract_pr_logs.sh b/scripts/extract_pr_logs.sh new file mode 100755 index 00000000..1ea8a66d --- /dev/null +++ b/scripts/extract_pr_logs.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Extract logs from failed GitHub Actions runs for a PR +# Usage: ./scripts/extract_pr_logs.sh [job_name_pattern] [--wait] +# +# Examples: +# ./scripts/extract_pr_logs.sh 329 # Latest failed run for PR #329 +# ./scripts/extract_pr_logs.sh 329 test # Only jobs matching "test" +# ./scripts/extract_pr_logs.sh 329 --wait # Wait for logs to be available +# ./scripts/extract_pr_logs.sh 18640062283 # Specific run ID + +set -euo pipefail + +INPUT="${1:-}" +JOB_PATTERN="${2:-}" +WAIT_FOR_LOGS=false + + +REPO_INFO=$(gh repo view --json owner,name --jq '{owner: .owner.login, name: .name}') +OWNER=$(echo "$REPO_INFO" | jq -r '.owner') +REPO=$(echo "$REPO_INFO" | jq -r '.name') +# Parse flags +if [[ "$JOB_PATTERN" == "--wait" ]]; then + WAIT_FOR_LOGS=true + JOB_PATTERN="" +elif [[ "${3:-}" == "--wait" ]]; then + WAIT_FOR_LOGS=true +fi + +if [[ -z "$INPUT" ]]; then + echo "❌ Usage: $0 [job_name_pattern]" >&2 + echo "" >&2 + echo "Examples:" >&2 + echo " $0 329 # Latest failed run for PR #329 (RECOMMENDED)" >&2 + echo " $0 329 test # Only jobs matching \"test\" from PR #329" >&2 + echo " $0 18640062283 # Specific run ID" >&2 + exit 1 +fi + +# Detect if input is a PR number or a run ID. +# Prefer PR interpretation whenever the number resolves to an existing PR. +if [[ "$INPUT" =~ ^[0-9]+$ ]] && gh pr view "$INPUT" --json number --jq '.number' >/dev/null 2>&1; then + PR_NUMBER="$INPUT" + echo "πŸ” Finding latest failed run for PR #$PR_NUMBER..." >&2 + + # Get the latest failed run for this PR + RUN_ID=$(gh pr checks "$PR_NUMBER" --json name,link,state --jq '.[] | select(.state == "FAILURE") | .link' | head -1 | sed -E 's|.*/runs/([0-9]+).*|\1|' || echo "") + + if [[ -z "$RUN_ID" ]]; then + echo "❌ No failed runs found for PR #$PR_NUMBER" >&2 + echo "" >&2 + echo "Current check status:" >&2 + gh pr checks "$PR_NUMBER" 2>&1 || true + exit 1 + fi + + echo "πŸ“‹ Found failed run: $RUN_ID" >&2 +else + RUN_ID="$INPUT" + echo "πŸ“‹ Fetching logs for run $RUN_ID..." >&2 +fi + +# Get all jobs for this run +JOBS=$(gh run view "$RUN_ID" --json jobs -q '.jobs[]' 2>/dev/null) + +if [[ -z "$JOBS" ]]; then + echo "❌ No jobs found for run $RUN_ID" >&2 + echo "" >&2 + echo "Check if run ID is correct:" >&2 + echo " gh run list --limit 10" >&2 + exit 1 +fi + +# Filter to failed jobs only (unless specific pattern requested) +if [[ -z "$JOB_PATTERN" ]]; then + FAILED_JOBS=$(echo "$JOBS" | jq -r 'select(.conclusion == "FAILURE" or .conclusion == "TIMED_OUT" or .conclusion == "CANCELLED")') + if [[ -n "$FAILED_JOBS" ]]; then + echo "🎯 Showing only failed jobs (use job_pattern to see others)" >&2 + JOBS="$FAILED_JOBS" + fi +fi + +# Parse jobs and filter by pattern if provided +if [[ -n "$JOB_PATTERN" ]]; then + MATCHING_JOBS=$(echo "$JOBS" | jq -r "select(.name | test(\"$JOB_PATTERN\"; \"i\")) | .databaseId") + if [[ -z "$MATCHING_JOBS" ]]; then + echo "❌ No jobs matching pattern '$JOB_PATTERN'" >&2 + echo "" >&2 + echo "Available jobs:" >&2 + echo "$JOBS" | jq -r '.name' >&2 + exit 1 + fi + JOB_IDS="$MATCHING_JOBS" +else + JOB_IDS=$(echo "$JOBS" | jq -r '.databaseId') +fi + +# Map job names to local commands for reproduction. +suggest_local_command() { + local job_name="$1" + case "$job_name" in + *"vendor"* | *"Vendor"*) + echo "πŸ’‘ Reproduce locally: make verify-vendor" + ;; + *"test"* | *"Test"*) + echo "πŸ’‘ Reproduce locally: make test" + ;; + *"build"* | *"Build"*) + echo "πŸ’‘ Reproduce locally: make build" + ;; + *"Lint GitHub Actions"* | *"actionlint"* | *"zizmor"*) + echo "πŸ’‘ Reproduce locally: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.10" + ;; + esac +} + +# Extract and display logs for each job +for JOB_ID in $JOB_IDS; do + JOB_INFO=$(echo "$JOBS" | jq -r "select(.databaseId == $JOB_ID)") + JOB_NAME=$(echo "$JOB_INFO" | jq -r '.name') + JOB_STATUS=$(echo "$JOB_INFO" | jq -r '.conclusion // .status') + + echo "" >&2 + echo "════════════════════════════════════════════════════════════" >&2 + echo "Job: $JOB_NAME (ID: $JOB_ID) - $JOB_STATUS" >&2 + echo "════════════════════════════════════════════════════════════" >&2 + + # Suggest local reproduction command + suggest_local_command "$JOB_NAME" >&2 + echo "" >&2 + + # Fetch logs with retry logic if --wait flag is set + MAX_RETRIES=3 + RETRY_COUNT=0 + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + # Use gh api to fetch logs (works for individual completed jobs even if run is in progress) + if gh api "/repos/${OWNER}/${REPO}/actions/jobs/$JOB_ID/logs" 2>/dev/null; then + break + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ "$WAIT_FOR_LOGS" = true ]; then + echo "⏳ Logs not ready yet, waiting 5 seconds... (attempt $RETRY_COUNT/$MAX_RETRIES)" >&2 + sleep 5 + else + echo "⚠️ Could not fetch logs for job $JOB_ID" >&2 + if [ "$WAIT_FOR_LOGS" = false ]; then + echo " Tip: Use --wait flag to retry if logs are still processing" >&2 + else + echo " (logs may have expired or are still processing)" >&2 + fi + break + fi + fi + done +done diff --git a/scripts/resolve_pr_comment.sh b/scripts/resolve_pr_comment.sh new file mode 100755 index 00000000..9c6d2a9a --- /dev/null +++ b/scripts/resolve_pr_comment.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + echo "Example: $0 PRRT_abc123" + exit 1 +fi + +THREAD_ID="$1" + +# shellcheck disable=SC2016 # Single quotes are intentional - this is a GraphQL query, not shell expansion +MUTATION='mutation($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { + id + isResolved + } + } +}' + +echo "Resolving review thread $THREAD_ID..." + +RESULT=$(gh api graphql \ + -f query="$MUTATION" \ + -F threadId="$THREAD_ID") + +RESOLVED=$(echo "$RESULT" | jq -r '.data.resolveReviewThread.thread.isResolved') + +if [ "$RESOLVED" == "true" ]; then + echo "βœ… Thread $THREAD_ID resolved" +else + echo "❌ Failed to resolve thread $THREAD_ID" + echo "$RESULT" + exit 1 +fi diff --git a/scripts/wait_pr_checks.sh b/scripts/wait_pr_checks.sh new file mode 100755 index 00000000..3c6e2d14 --- /dev/null +++ b/scripts/wait_pr_checks.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wait for PR checks to complete +# Usage: ./scripts/wait_pr_checks.sh + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +PR_NUMBER=$1 + +# Check for dirty working tree +if ! git diff-index --quiet HEAD --; then + echo "❌ Error: You have uncommitted changes in your working directory." >&2 + echo "" >&2 + git status --short >&2 + echo "" >&2 + echo "Please commit or stash your changes before checking PR status." >&2 + exit 1 +fi + +# Get current branch name +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Get remote tracking branch +REMOTE_BRANCH=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || echo "") + +if [[ -z "$REMOTE_BRANCH" ]]; then + echo "⚠️ Current branch '$CURRENT_BRANCH' has no upstream branch." >&2 + echo "Setting upstream to origin/$CURRENT_BRANCH..." >&2 + + # Try to set upstream + if git push -u origin "$CURRENT_BRANCH" 2>&1; then + echo "βœ… Upstream set successfully!" >&2 + REMOTE_BRANCH="origin/$CURRENT_BRANCH" + else + echo "❌ Error: Failed to set upstream branch." >&2 + echo "You may need to push manually: git push -u origin $CURRENT_BRANCH" >&2 + exit 1 + fi +fi + +# Fetch latest remote state before comparing +git fetch origin "$CURRENT_BRANCH" --quiet 2>/dev/null || true + +# Check if local and remote are in sync +LOCAL_HASH=$(git rev-parse HEAD) +REMOTE_HASH=$(git rev-parse "$REMOTE_BRANCH") + +if [[ "$LOCAL_HASH" != "$REMOTE_HASH" ]]; then + echo "❌ Error: Local branch is not in sync with remote." >&2 + echo "" >&2 + echo "Local: $LOCAL_HASH" >&2 + echo "Remote: $REMOTE_HASH" >&2 + echo "" >&2 + + # Check if we're ahead, behind, or diverged + if git merge-base --is-ancestor "$REMOTE_HASH" HEAD 2>/dev/null; then + AHEAD=$(git rev-list --count "$REMOTE_BRANCH"..HEAD) + echo "Your branch is $AHEAD commit(s) ahead of '$REMOTE_BRANCH'." >&2 + echo "Push your changes with: git push" >&2 + elif git merge-base --is-ancestor HEAD "$REMOTE_HASH" 2>/dev/null; then + BEHIND=$(git rev-list --count HEAD.."$REMOTE_BRANCH") + echo "Your branch is $BEHIND commit(s) behind '$REMOTE_BRANCH'." >&2 + echo "Pull the latest changes with: git pull" >&2 + else + echo "Your branch has diverged from '$REMOTE_BRANCH'." >&2 + echo "You may need to rebase or merge." >&2 + fi + + exit 1 +fi + +echo "⏳ Waiting for PR #$PR_NUMBER checks to complete..." +echo "" + +while true; do + # Get PR status + STATUS=$(gh pr view "$PR_NUMBER" --json mergeable,mergeStateStatus,state 2>/dev/null || echo "error") + + if [ "$STATUS" = "error" ]; then + echo "❌ Failed to get PR status. Does PR #$PR_NUMBER exist?" + exit 1 + fi + + PR_STATE=$(echo "$STATUS" | jq -r '.state') + + # Check if PR is already merged + if [ "$PR_STATE" = "MERGED" ]; then + echo "βœ… PR #$PR_NUMBER has been merged!" + exit 0 + fi + + # Check if PR is closed without merging + if [ "$PR_STATE" = "CLOSED" ]; then + echo "❌ PR #$PR_NUMBER is closed (not merged)!" + exit 1 + fi + + MERGEABLE=$(echo "$STATUS" | jq -r '.mergeable') + MERGE_STATE=$(echo "$STATUS" | jq -r '.mergeStateStatus') + + # Check for bad merge status + if [ "$MERGEABLE" = "CONFLICTING" ]; then + echo "❌ PR has merge conflicts!" + exit 1 + fi + + if [ "$MERGE_STATE" = "DIRTY" ]; then + echo "❌ PR has merge conflicts!" + exit 1 + fi + + if [ "$MERGE_STATE" = "BEHIND" ]; then + echo "❌ PR is behind base branch. Rebase needed." + echo "" + echo "Run:" + echo " git fetch origin" + echo " git rebase origin/main" + echo " git push --force-with-lease" + exit 1 + fi + + # Get check status + CHECKS=$(gh pr checks "$PR_NUMBER" 2>&1 || echo "pending") + + # Check for failures + if echo "$CHECKS" | grep -q "fail"; then + echo "❌ Some checks failed:" + echo "" + gh pr checks "$PR_NUMBER" + echo "" + echo "πŸ’‘ To extract detailed logs from the failed run:" + echo " ./scripts/extract_pr_logs.sh $PR_NUMBER" + echo " ./scripts/extract_pr_logs.sh $PR_NUMBER " + echo "" + echo "πŸ’‘ Common local repro commands for this repo:" + echo " make verify-vendor" + echo " make test" + echo " make build" + exit 1 + fi + + # Check for unresolved review comments in the hot loop + if ! ./scripts/check_pr_reviews.sh "$PR_NUMBER" >/dev/null 2>&1; then + echo "" + echo "❌ Unresolved review comments found!" + echo " πŸ‘‰ Tip: run ./scripts/check_pr_reviews.sh $PR_NUMBER to list them." + ./scripts/check_pr_reviews.sh "$PR_NUMBER" + exit 1 + fi + + # Check if all checks passed and merge state is clean + if echo "$CHECKS" | grep -q "pass" && ! echo "$CHECKS" | grep -qE "pending|fail"; then + if [ "$MERGE_STATE" = "CLEAN" ]; then + # Check for unresolved Codex comments + echo "βœ… All checks passed!" + echo "" + gh pr checks "$PR_NUMBER" + echo "" + echo "πŸ€– Checking for unresolved Codex comments..." + if ./scripts/check_codex_comments.sh "$PR_NUMBER"; then + echo "" + echo "βœ… PR is ready to merge!" + exit 0 + else + echo "" + echo "❌ Please resolve Codex comments before merging." + echo " πŸ‘‰ Tip: use ./scripts/check_pr_reviews.sh $PR_NUMBER to list unresolved comments." + exit 1 + fi + elif [ "$MERGE_STATE" = "BLOCKED" ]; then + echo "⏳ All checks passed but still blocked (waiting for required checks)..." + fi + else + # Show current status + echo -ne "\r⏳ Checks in progress... (${MERGE_STATE}) " + fi + + sleep 5 +done diff --git a/scripts/wait_pr_codex.sh b/scripts/wait_pr_codex.sh new file mode 100755 index 00000000..3e4044c9 --- /dev/null +++ b/scripts/wait_pr_codex.sh @@ -0,0 +1,352 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wait for Codex to respond to a `@codex review` request. +# +# Usage: ./scripts/wait_pr_codex.sh +# +# Exits: +# 0 - Codex approved (posts an explicit approval comment) +# 1 - Codex left comments to address OR failed to review (e.g. rate limit) + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +PR_NUMBER=$1 +BOT_LOGIN_GRAPHQL="chatgpt-codex-connector" + +if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "❌ PR number must be numeric. Got: '$PR_NUMBER'" + exit 1 +fi + +# Keep these regexes in sync with ./scripts/check_codex_comments.sh. +CODEX_APPROVAL_REGEX="Didn't find any major issues" +CODEX_RATE_LIMIT_REGEX="usage limits have been reached" + +# Check for dirty working tree +if ! git diff-index --quiet HEAD --; then + echo "❌ Error: You have uncommitted changes in your working directory." >&2 + echo "" >&2 + git status --short >&2 + echo "" >&2 + echo "Please commit or stash your changes before checking PR status." >&2 + exit 1 +fi + +# Get current branch name +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Get remote tracking branch +REMOTE_BRANCH=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || echo "") + +if [[ -z "$REMOTE_BRANCH" ]]; then + echo "⚠️ Current branch '$CURRENT_BRANCH' has no upstream branch." >&2 + echo "Setting upstream to origin/$CURRENT_BRANCH..." >&2 + + # Try to set upstream + if git push -u origin "$CURRENT_BRANCH" 2>&1; then + echo "βœ… Upstream set successfully!" >&2 + REMOTE_BRANCH="origin/$CURRENT_BRANCH" + else + echo "❌ Error: Failed to set upstream branch." >&2 + echo "You may need to push manually: git push -u origin $CURRENT_BRANCH" >&2 + exit 1 + fi +fi + +# Fetch latest remote state before comparing +git fetch origin "$CURRENT_BRANCH" --quiet 2>/dev/null || true + +# Check if local and remote are in sync +LOCAL_HASH=$(git rev-parse HEAD) +REMOTE_HASH=$(git rev-parse "$REMOTE_BRANCH") + +if [[ "$LOCAL_HASH" != "$REMOTE_HASH" ]]; then + echo "❌ Error: Local branch is not in sync with remote." >&2 + echo "" >&2 + echo "Local: $LOCAL_HASH" >&2 + echo "Remote: $REMOTE_HASH" >&2 + echo "" >&2 + + # Check if we're ahead, behind, or diverged + if git merge-base --is-ancestor "$REMOTE_HASH" HEAD 2>/dev/null; then + AHEAD=$(git rev-list --count "$REMOTE_BRANCH"..HEAD) + echo "Your branch is $AHEAD commit(s) ahead of '$REMOTE_BRANCH'." >&2 + echo "Push your changes with: git push" >&2 + elif git merge-base --is-ancestor HEAD "$REMOTE_HASH" 2>/dev/null; then + BEHIND=$(git rev-list --count HEAD.."$REMOTE_BRANCH") + echo "Your branch is $BEHIND commit(s) behind '$REMOTE_BRANCH'." >&2 + echo "Pull the latest changes with: git pull" >&2 + else + echo "Your branch has diverged from '$REMOTE_BRANCH'." >&2 + echo "You may need to rebase or merge." >&2 + fi + + exit 1 +fi + +# shellcheck disable=SC2016 # Single quotes are intentional - these are GraphQL queries. +PR_STATE_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + state + } + } +}' + +# shellcheck disable=SC2016 # Single quotes are intentional - these are GraphQL queries. +COMMENTS_QUERY='query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + comments(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { login } + body + createdAt + isMinimized + } + } + } + } +}' + +# shellcheck disable=SC2016 # Single quotes are intentional - these are GraphQL queries. +THREADS_QUERY='query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + comments(first: 1) { + nodes { + id + author { login } + body + createdAt + path + line + } + } + } + } + } + } +}' + +REPO_INFO=$(gh repo view --json owner,name --jq '{owner: .owner.login, name: .name}') +OWNER=$(echo "$REPO_INFO" | jq -r '.owner') +REPO=$(echo "$REPO_INFO" | jq -r '.name') + +# Depot runners sometimes hit transient network timeouts to api.github.com. +# Retry the GraphQL request a few times before failing. +MAX_ATTEMPTS=5 +BACKOFF_SECS=2 + +FETCH_GRAPHQL_WITH_RETRY() { + local query="$1" + shift + + local attempt + local backoff + backoff="$BACKOFF_SECS" + + for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + if gh api graphql \ + -f query="$query" \ + -F owner="$OWNER" \ + -F repo="$REPO" \ + -F pr="$PR_NUMBER" \ + "$@"; then + return 0 + fi + + if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then + echo "❌ GraphQL query failed after ${MAX_ATTEMPTS} attempts" >&2 + return 1 + fi + + echo "⚠️ GraphQL query failed (attempt ${attempt}/${MAX_ATTEMPTS}); retrying in ${backoff}s..." >&2 + sleep "$backoff" + backoff=$((backoff * 2)) + done +} + +FETCH_PR_STATE() { + FETCH_GRAPHQL_WITH_RETRY "$PR_STATE_QUERY" +} + +FETCH_ALL_COMMENTS() { + local comments_cursor="" + local all_comments='[]' + local result + local page_comments + local has_next + + while true; do + if [ -n "$comments_cursor" ]; then + result=$(FETCH_GRAPHQL_WITH_RETRY "$COMMENTS_QUERY" -F cursor="$comments_cursor") + else + result=$(FETCH_GRAPHQL_WITH_RETRY "$COMMENTS_QUERY") + fi + + if [ "$(echo "$result" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then + echo "❌ PR #${PR_NUMBER} does not exist in ${OWNER}/${REPO}." >&2 + return 1 + fi + + page_comments=$(echo "$result" | jq '.data.repository.pullRequest.comments.nodes') + all_comments=$(jq -cn --argjson all "$all_comments" --argjson page "$page_comments" '$all + $page') + + has_next=$(echo "$result" | jq -r '.data.repository.pullRequest.comments.pageInfo.hasNextPage') + if [ "$has_next" != "true" ]; then + break + fi + + comments_cursor=$(echo "$result" | jq -r '.data.repository.pullRequest.comments.pageInfo.endCursor') + if [ -z "$comments_cursor" ] || [ "$comments_cursor" = "null" ]; then + echo "❌ Assertion failed: comments pagination cursor missing while hasNextPage=true" >&2 + return 1 + fi + done + + echo "$all_comments" +} + +FETCH_ALL_THREADS() { + local threads_cursor="" + local all_threads='[]' + local result + local page_threads + local has_next + + while true; do + if [ -n "$threads_cursor" ]; then + result=$(FETCH_GRAPHQL_WITH_RETRY "$THREADS_QUERY" -F cursor="$threads_cursor") + else + result=$(FETCH_GRAPHQL_WITH_RETRY "$THREADS_QUERY") + fi + + if [ "$(echo "$result" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then + echo "❌ PR #${PR_NUMBER} does not exist in ${OWNER}/${REPO}." >&2 + return 1 + fi + + page_threads=$(echo "$result" | jq '.data.repository.pullRequest.reviewThreads.nodes') + all_threads=$(jq -cn --argjson all "$all_threads" --argjson page "$page_threads" '$all + $page') + + has_next=$(echo "$result" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage') + if [ "$has_next" != "true" ]; then + break + fi + + threads_cursor=$(echo "$result" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor') + if [ -z "$threads_cursor" ] || [ "$threads_cursor" = "null" ]; then + echo "❌ Assertion failed: review thread pagination cursor missing while hasNextPage=true" >&2 + return 1 + fi + done + + echo "$all_threads" +} + +echo "⏳ Waiting for Codex review on PR #$PR_NUMBER..." +echo "" +echo "Tip: after you comment '@codex review', Codex will respond with either:" +echo " - review comments / threads to address (script exits 1)" +echo " - an explicit approval comment (script exits 0)" +echo "" + +while true; do + PR_STATE_RESULT=$(FETCH_PR_STATE) + PR_STATE=$(echo "$PR_STATE_RESULT" | jq -r '.data.repository.pullRequest.state // empty') + + if [[ -z "$PR_STATE" ]]; then + echo "❌ Unable to fetch PR state for #$PR_NUMBER in ${OWNER}/${REPO}." >&2 + exit 1 + fi + + if [ "$PR_STATE" = "MERGED" ]; then + echo "βœ… PR #$PR_NUMBER has been merged!" + exit 0 + fi + + if [ "$PR_STATE" = "CLOSED" ]; then + echo "❌ PR #$PR_NUMBER is closed (not merged)!" + exit 1 + fi + + ALL_COMMENTS=$(FETCH_ALL_COMMENTS) + ALL_THREADS=$(FETCH_ALL_THREADS) + + # Ignore Codex's own comments since they mention "@codex review" in boilerplate. + REQUEST_AT=$(echo "$ALL_COMMENTS" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" '[.[] | select(.author.login != $bot and (.body | contains("@codex review")))] | sort_by(.createdAt) | last | .createdAt // empty') + + if [[ -z "$REQUEST_AT" ]]; then + echo "❌ No '@codex review' comment found on PR #$PR_NUMBER." >&2 + echo "" >&2 + echo "Post one (example):" >&2 + echo " gh pr comment $PR_NUMBER --body-file - <<'EOF'" >&2 + echo " @codex review" >&2 + echo " " >&2 + echo " Please take another look." >&2 + echo " EOF" >&2 + exit 1 + fi + + # If Codex can't run (usage limits, etc) it posts a comment we shouldn't treat as "approval". + RATE_LIMIT_COMMENT=$(echo "$ALL_COMMENTS" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" --arg request_at "$REQUEST_AT" --arg regex "$CODEX_RATE_LIMIT_REGEX" '[.[] | select(.author.login == $bot and .createdAt > $request_at and (.body | test($regex))) | {createdAt, body}] | sort_by(.createdAt) | last // empty | .body // empty') + + if [[ -n "$RATE_LIMIT_COMMENT" ]]; then + echo "" + echo "❌ Codex was unable to review (usage limits)." + echo "" + echo "$RATE_LIMIT_COMMENT" + exit 1 + fi + + APPROVAL_COMMENT=$(echo "$ALL_COMMENTS" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" --arg request_at "$REQUEST_AT" --arg regex "$CODEX_APPROVAL_REGEX" '[.[] | select(.author.login == $bot and .createdAt > $request_at and (.body | test($regex))) | {createdAt, body}] | sort_by(.createdAt) | last // empty | .body // empty') + + if [[ -n "$APPROVAL_COMMENT" ]]; then + echo "" + echo "βœ… Codex approved PR #$PR_NUMBER" + echo "" + echo "$APPROVAL_COMMENT" + exit 0 + fi + + CODEX_RESPONSE_COUNT_COMMENTS=$(echo "$ALL_COMMENTS" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" --arg request_at "$REQUEST_AT" '[.[] | select(.author.login == $bot and .createdAt > $request_at)] | length') + CODEX_RESPONSE_COUNT_THREADS=$(echo "$ALL_THREADS" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" --arg request_at "$REQUEST_AT" '[.[] | select((.comments.nodes | length) > 0 and .comments.nodes[0].author.login == $bot and .comments.nodes[0].createdAt > $request_at)] | length') + CODEX_RESPONSE_COUNT=$((CODEX_RESPONSE_COUNT_COMMENTS + CODEX_RESPONSE_COUNT_THREADS)) + + if [ "$CODEX_RESPONSE_COUNT" -eq 0 ]; then + echo -ne "\r⏳ Waiting for Codex response... (requested at ${REQUEST_AT}) " + sleep 5 + continue + fi + + # Codex responded to the latest @codex review request; defer to check_codex_comments.sh for + # unresolved comment/thread detection so we don't duplicate filtering logic here. + if ! CHECK_OUTPUT=$(./scripts/check_codex_comments.sh "$PR_NUMBER" 2>&1); then + echo "" + echo "$CHECK_OUTPUT" + exit 1 + fi + + echo "" + echo "❌ Codex responded, but no explicit approval comment was found after the latest '@codex review'." + echo " πŸ‘‰ If you expected approval, re-comment '@codex review' and run this script again." + exit 1 +done diff --git a/scripts/wait_pr_ready.sh b/scripts/wait_pr_ready.sh new file mode 100755 index 00000000..acc53d87 --- /dev/null +++ b/scripts/wait_pr_ready.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wait for a PR to become merge-ready by enforcing the Codex + CI loop. +# Usage: ./scripts/wait_pr_ready.sh +# +# This script orchestrates: +# 1) wait_pr_codex.sh - waits for an explicit Codex response/approval +# 2) wait_pr_checks.sh - waits for required CI checks and mergeability +# +# It cannot auto-fix feedback; if either phase fails, address feedback, push, +# re-request review (`@codex review`), then run this script again. + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +PR_NUMBER="$1" +if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "❌ PR number must be numeric. Got: '$PR_NUMBER'" >&2 + exit 1 +fi + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +WAIT_CODEX_SCRIPT="$SCRIPT_DIR/wait_pr_codex.sh" +WAIT_CHECKS_SCRIPT="$SCRIPT_DIR/wait_pr_checks.sh" + +for required in "$WAIT_CODEX_SCRIPT" "$WAIT_CHECKS_SCRIPT"; do + if [ ! -x "$required" ]; then + echo "❌ Required executable script is missing or not executable: $required" >&2 + exit 1 + fi +done + +for required_cmd in gh jq git; do + if ! command -v "$required_cmd" >/dev/null 2>&1; then + echo "❌ Missing required command: $required_cmd" >&2 + exit 1 + fi +done + +echo "🚦 Waiting for PR #$PR_NUMBER to become ready (Codex + CI)..." +echo "" + +echo "Step 1/2: Waiting for Codex review on latest @codex review request..." +if ! "$WAIT_CODEX_SCRIPT" "$PR_NUMBER"; then + echo "" + echo "❌ Codex phase did not pass." + echo " Address feedback (or retry if Codex was rate-limited), push, and request review again:" + echo "" + echo " gh pr comment $PR_NUMBER --body-file - <<'EOF'" + echo " @codex review" + echo "" + echo " Please take another look." + echo " EOF" + echo "" + exit 1 +fi + +echo "" +echo "βœ… Codex approved the latest review request." +echo "" +echo "Step 2/2: Waiting for required checks and mergeability..." +if ! "$WAIT_CHECKS_SCRIPT" "$PR_NUMBER"; then + echo "" + echo "❌ CI/mergeability phase did not pass." + echo " Fix issues locally, push, and rerun this script." + exit 1 +fi + +echo "" +echo "πŸŽ‰ PR #$PR_NUMBER is ready: Codex approved and required checks passed."