From 5ae8f52f98755f0b50221c539c3779a9de17f214 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Feb 2026 11:34:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20ci:=20add=20Codex=20PR=20loo?= =?UTF-8?q?p=20tooling=20and=20readiness=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring over mux pull-request skill and supporting scripts into this Go repo, then adapt them for coder-k8s workflows. - add PR skill at .mux/skills/pull-requests/SKILL.md - add Codex/PR scripts under scripts/ - add scripts/wait_pr_ready.sh wrapper for one-command Codex+CI waiting - add project AGENTS.md guidance (non-TypeScript sections adapted for Go) - add codex-comments job to .github/workflows/ci.yaml Validation: - bash -n scripts/*.sh - actionlint .github/workflows/ci.yaml - make verify-vendor - make test - make build --- _Generated with `mux` β€’ Model: `openai:gpt-5.3-codex` β€’ Thinking: `xhigh` β€’ Cost: `$0.82`_ --- .github/workflows/ci.yaml | 19 +++ .mux/skills/pull-requests/SKILL.md | 129 ++++++++++++++++ AGENTS.md | 21 ++- scripts/check_codex_comments.sh | 113 ++++++++++++++ scripts/check_pr_reviews.sh | 66 ++++++++ scripts/extract_pr_logs.sh | 154 ++++++++++++++++++ scripts/resolve_pr_comment.sh | 36 +++++ scripts/wait_pr_checks.sh | 183 ++++++++++++++++++++++ scripts/wait_pr_codex.sh | 240 +++++++++++++++++++++++++++++ scripts/wait_pr_ready.sh | 73 +++++++++ 10 files changed, 1029 insertions(+), 5 deletions(-) create mode 100644 .mux/skills/pull-requests/SKILL.md create mode 100755 scripts/check_codex_comments.sh create mode 100755 scripts/check_pr_reviews.sh create mode 100755 scripts/extract_pr_logs.sh create mode 100755 scripts/resolve_pr_comment.sh create mode 100755 scripts/wait_pr_checks.sh create mode 100755 scripts/wait_pr_codex.sh create mode 100755 scripts/wait_pr_ready.sh 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..d11cd170 --- /dev/null +++ b/scripts/check_codex_comments.sh @@ -0,0 +1,113 @@ +#!/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" + +echo "Checking for unresolved Codex comments in PR #${PR_NUMBER}..." + +# Use GraphQL to get all comments (including minimized status) +# shellcheck disable=SC2016 # Single quotes are intentional - this is a GraphQL query, not shell expansion +GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + comments(first: 100) { + nodes { + id + author { login } + body + createdAt + isMinimized + } + } + reviewThreads(first: 100) { + 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 the required check. +MAX_ATTEMPTS=5 +BACKOFF_SECS=2 + +for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + if RESULT=$(gh api graphql \ + -f query="$GRAPHQL_QUERY" \ + -F owner="$OWNER" \ + -F repo="$REPO" \ + -F pr="$PR_NUMBER"); then + break + fi + + if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then + echo "❌ GraphQL query failed after ${MAX_ATTEMPTS} attempts" + exit 1 + fi + + echo "⚠️ GraphQL query failed (attempt ${attempt}/${MAX_ATTEMPTS}); retrying in ${BACKOFF_SECS}s..." + sleep "$BACKOFF_SECS" + BACKOFF_SECS=$((BACKOFF_SECS * 2)) +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 "$RESULT" | jq "[.data.repository.pullRequest.comments.nodes[] | 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 "$RESULT" | jq "[.data.repository.pullRequest.reviewThreads.nodes[] | 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 +else + echo "βœ… No unresolved Codex comments found" + exit 0 +fi diff --git a/scripts/check_pr_reviews.sh b/scripts/check_pr_reviews.sh new file mode 100755 index 00000000..1678d76f --- /dev/null +++ b/scripts/check_pr_reviews.sh @@ -0,0 +1,66 @@ +#!/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" + +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, not shell expansion +GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 1) { + nodes { + author { login } + body + diffHunk + commit { oid } + } + } + } + } + } + } +}' + +RESULT=$(gh api graphql \ + -f query="$GRAPHQL_QUERY" \ + -F owner="$OWNER" \ + -F repo="$REPO" \ + -F pr="$PR_NUMBER") + +if [ "$(echo "$RESULT" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then + echo "❌ PR #$PR_NUMBER was not found in ${OWNER}/${REPO}." + exit 1 +fi + +UNRESOLVED=$(echo "$RESULT" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[] | 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..bf21f902 --- /dev/null +++ b/scripts/extract_pr_logs.sh @@ -0,0 +1,154 @@ +#!/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 PR number or run ID (run IDs are much longer) +if [[ "$INPUT" =~ ^[0-9]{1,5}$ ]]; 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..d85215b1 --- /dev/null +++ b/scripts/wait_pr_codex.sh @@ -0,0 +1,240 @@ +#!/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" + +# 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 + +# Use GraphQL to get all comments (including minimized status). +# shellcheck disable=SC2016 # Single quotes are intentional - this is a GraphQL query, not shell expansion +GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + state + comments(last: 100) { + nodes { + id + author { login } + body + createdAt + isMinimized + } + } + reviewThreads(last: 100) { + 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_PR_DATA() { + local attempt + local backoff + + backoff="$BACKOFF_SECS" + + for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + if gh api graphql \ + -f query="$GRAPHQL_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 +} + +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 + RESULT=$(FETCH_PR_DATA) + + PR_STATE=$(echo "$RESULT" | jq -r '.data.repository.pullRequest.state') + + 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 + + # Ignore Codex's own comments since they mention "@codex review" in boilerplate. + REQUEST_AT=$(echo "$RESULT" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" '[.data.repository.pullRequest.comments.nodes[] | 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 "$RESULT" | jq -r "[.data.repository.pullRequest.comments.nodes[] | select(.author.login == \"${BOT_LOGIN_GRAPHQL}\" and .createdAt > \"${REQUEST_AT}\" and (.body | test(\"${CODEX_RATE_LIMIT_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 "$RESULT" | jq -r "[.data.repository.pullRequest.comments.nodes[] | select(.author.login == \"${BOT_LOGIN_GRAPHQL}\" and .createdAt > \"${REQUEST_AT}\" and (.body | test(\"${CODEX_APPROVAL_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=$(echo "$RESULT" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" --arg request_at "$REQUEST_AT" '([.data.repository.pullRequest.comments.nodes[] | select(.author.login == $bot and .createdAt > $request_at)] | length) + ([.data.repository.pullRequest.reviewThreads.nodes[] | select(.comments.nodes[0].author.login == $bot and .comments.nodes[0].createdAt > $request_at)] | length)') + + 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 the 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." From 4641be56a78e4daabc3b499122ca6d8ac61798e1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Feb 2026 11:43:18 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20Codex=20fee?= =?UTF-8?q?dback=20on=20PR=20review=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - paginate Codex comment + review thread queries in check_codex_comments.sh - paginate review threads in check_pr_reviews.sh - treat numeric input as PR when it resolves in extract_pr_logs.sh Validation: - bash -n scripts/*.sh - make verify-vendor - make test - make build --- _Generated with `mux` β€’ Model: `openai:gpt-5.3-codex` β€’ Thinking: `xhigh` β€’ Cost: `$0.82`_ --- scripts/check_codex_comments.sh | 148 +++++++++++++++++++++++++------- scripts/check_pr_reviews.sh | 62 ++++++++++--- scripts/extract_pr_logs.sh | 5 +- 3 files changed, 171 insertions(+), 44 deletions(-) diff --git a/scripts/check_codex_comments.sh b/scripts/check_codex_comments.sh index d11cd170..d4c9a342 100755 --- a/scripts/check_codex_comments.sh +++ b/scripts/check_codex_comments.sh @@ -9,14 +9,31 @@ 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}..." -# Use GraphQL to get all comments (including minimized status) -# shellcheck disable=SC2016 # Single quotes are intentional - this is a GraphQL query, not shell expansion -GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { +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) { + comments(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } nodes { id author { login } @@ -25,7 +42,19 @@ GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { isMinimized } } - reviewThreads(first: 100) { + } + } +}' + +# 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 @@ -45,42 +74,103 @@ GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { } }' -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') +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 -# 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 + 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') -for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do - if RESULT=$(gh api graphql \ - -f query="$GRAPHQL_QUERY" \ - -F owner="$OWNER" \ - -F repo="$REPO" \ - -F pr="$PR_NUMBER"); then + HAS_NEXT=$(echo "$COMMENTS_RESULT" | jq -r '.data.repository.pullRequest.comments.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then break fi - if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then - echo "❌ GraphQL query failed after ${MAX_ATTEMPTS} attempts" + 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 - echo "⚠️ GraphQL query failed (attempt ${attempt}/${MAX_ATTEMPTS}); retrying in ${BACKOFF_SECS}s..." - sleep "$BACKOFF_SECS" - BACKOFF_SECS=$((BACKOFF_SECS * 2)) + 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 "$RESULT" | jq "[.data.repository.pullRequest.comments.nodes[] | 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_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 "$RESULT" | jq "[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and .comments.nodes[0].author.login == \"${BOT_LOGIN_GRAPHQL}\")]") +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)) @@ -88,7 +178,7 @@ 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 +if [ "$TOTAL_UNRESOLVED" -gt 0 ]; then echo "" echo "❌ Found ${TOTAL_UNRESOLVED} unresolved comment(s) from Codex in PR #${PR_NUMBER}" echo "" @@ -107,7 +197,7 @@ if [ $TOTAL_UNRESOLVED -gt 0 ]; then echo "" echo "Please address or resolve all Codex comments before merging." exit 1 -else - echo "βœ… No unresolved Codex comments found" - exit 0 fi + +echo "βœ… No unresolved Codex comments found" +exit 0 diff --git a/scripts/check_pr_reviews.sh b/scripts/check_pr_reviews.sh index 1678d76f..49df5fb9 100755 --- a/scripts/check_pr_reviews.sh +++ b/scripts/check_pr_reviews.sh @@ -11,16 +11,24 @@ if [ $# -eq 0 ]; then 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, not shell expansion -GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { +# 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) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } nodes { id isResolved @@ -38,18 +46,46 @@ GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { } }' -RESULT=$(gh api graphql \ - -f query="$GRAPHQL_QUERY" \ - -F owner="$OWNER" \ - -F repo="$REPO" \ - -F pr="$PR_NUMBER") +THREAD_CURSOR="" +ALL_THREADS='[]' -if [ "$(echo "$RESULT" | jq -r '.data.repository.pullRequest == null')" = "true" ]; then - echo "❌ PR #$PR_NUMBER was not found in ${OWNER}/${REPO}." - exit 1 -fi +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 "$RESULT" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[] | 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 // "")}') +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:" diff --git a/scripts/extract_pr_logs.sh b/scripts/extract_pr_logs.sh index bf21f902..1ea8a66d 100755 --- a/scripts/extract_pr_logs.sh +++ b/scripts/extract_pr_logs.sh @@ -36,8 +36,9 @@ if [[ -z "$INPUT" ]]; then exit 1 fi -# Detect if input is PR number or run ID (run IDs are much longer) -if [[ "$INPUT" =~ ^[0-9]{1,5}$ ]]; then +# 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 From d8ef60b0a767904ad5b382bdeec09571eb04a951 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Feb 2026 11:49:36 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20paginate=20wait=5Fpr?= =?UTF-8?q?=5Fcodex=20comment/thread=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle PRs with more than 100 comments/review threads by paginating the GraphQL fetches in wait_pr_codex.sh. Validation: - bash -n scripts/*.sh - make verify-vendor - make test - make build --- _Generated with `mux` β€’ Model: `openai:gpt-5.3-codex` β€’ Thinking: `xhigh` β€’ Cost: `$0.82`_ --- scripts/wait_pr_codex.sh | 156 +++++++++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 22 deletions(-) diff --git a/scripts/wait_pr_codex.sh b/scripts/wait_pr_codex.sh index d85215b1..3e4044c9 100755 --- a/scripts/wait_pr_codex.sh +++ b/scripts/wait_pr_codex.sh @@ -17,6 +17,11 @@ 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" @@ -83,13 +88,24 @@ if [[ "$LOCAL_HASH" != "$REMOTE_HASH" ]]; then exit 1 fi -# Use GraphQL to get all comments (including minimized status). -# shellcheck disable=SC2016 # Single quotes are intentional - this is a GraphQL query, not shell expansion -GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { +# 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 - comments(last: 100) { + } + } +}' + +# 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 } @@ -98,7 +114,19 @@ GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { isMinimized } } - reviewThreads(last: 100) { + } + } +}' + +# 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 @@ -127,18 +155,21 @@ REPO=$(echo "$REPO_INFO" | jq -r '.name') MAX_ATTEMPTS=5 BACKOFF_SECS=2 -FETCH_PR_DATA() { +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="$GRAPHQL_QUERY" \ + -f query="$query" \ -F owner="$OWNER" \ -F repo="$REPO" \ - -F pr="$PR_NUMBER"; then + -F pr="$PR_NUMBER" \ + "$@"; then return 0 fi @@ -153,22 +184,99 @@ FETCH_PR_DATA() { done } -echo "⏳ Waiting for Codex review on PR #$PR_NUMBER..." +FETCH_PR_STATE() { + FETCH_GRAPHQL_WITH_RETRY "$PR_STATE_QUERY" +} -echo "" +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 -echo "Tip: after you comment '@codex review', Codex will respond with either:" + 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 -echo " - review comments / threads to address (script exits 1)" + page_comments=$(echo "$result" | jq '.data.repository.pullRequest.comments.nodes') + all_comments=$(jq -cn --argjson all "$all_comments" --argjson page "$page_comments" '$all + $page') -echo " - an explicit approval comment (script exits 0)" + 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 - RESULT=$(FETCH_PR_DATA) + PR_STATE_RESULT=$(FETCH_PR_STATE) + PR_STATE=$(echo "$PR_STATE_RESULT" | jq -r '.data.repository.pullRequest.state // empty') - PR_STATE=$(echo "$RESULT" | jq -r '.data.repository.pullRequest.state') + 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!" @@ -180,8 +288,11 @@ while true; do 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 "$RESULT" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" '[.data.repository.pullRequest.comments.nodes[] | select(.author.login != $bot and (.body | contains("@codex review")))] | sort_by(.createdAt) | last | .createdAt // empty') + 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 @@ -196,7 +307,7 @@ while true; do fi # If Codex can't run (usage limits, etc) it posts a comment we shouldn't treat as "approval". - RATE_LIMIT_COMMENT=$(echo "$RESULT" | jq -r "[.data.repository.pullRequest.comments.nodes[] | select(.author.login == \"${BOT_LOGIN_GRAPHQL}\" and .createdAt > \"${REQUEST_AT}\" and (.body | test(\"${CODEX_RATE_LIMIT_REGEX}\"))) | {createdAt, body}] | sort_by(.createdAt) | last // empty | .body // empty") + 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 "" @@ -206,7 +317,7 @@ while true; do exit 1 fi - APPROVAL_COMMENT=$(echo "$RESULT" | jq -r "[.data.repository.pullRequest.comments.nodes[] | select(.author.login == \"${BOT_LOGIN_GRAPHQL}\" and .createdAt > \"${REQUEST_AT}\" and (.body | test(\"${CODEX_APPROVAL_REGEX}\"))) | {createdAt, body}] | sort_by(.createdAt) | last // empty | .body // empty") + 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 "" @@ -216,7 +327,9 @@ while true; do exit 0 fi - CODEX_RESPONSE_COUNT=$(echo "$RESULT" | jq -r --arg bot "$BOT_LOGIN_GRAPHQL" --arg request_at "$REQUEST_AT" '([.data.repository.pullRequest.comments.nodes[] | select(.author.login == $bot and .createdAt > $request_at)] | length) + ([.data.repository.pullRequest.reviewThreads.nodes[] | select(.comments.nodes[0].author.login == $bot and .comments.nodes[0].createdAt > $request_at)] | length)') + 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}) " @@ -225,7 +338,7 @@ while true; do 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 the filtering logic here. + # 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" @@ -236,5 +349,4 @@ while true; do 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