gh-pilot
GH Pilot
Run a lightweight Copilot review loop on one PR until feedback is fully addressed.
Inputs
- PR number (optional): If not provided, detect the PR for the current branch.
Instructions
1. Identify the PR
gh pr view --json number,headRefName -q '{number: .number, branch: .headRefName}'
2. Loop
Repeat this cycle. Max 5 iterations to avoid runaway loops.
A. Fetch Copilot state first
gh api --paginate repos/<OWNER>/<REPO>/pulls/<PR_NUMBER>/reviews
Fetch review threads with resolution state (paginate if needed):
THREADS_FILE="$(mktemp)"
CURSOR=""
while true; do
PAGE=$(gh api graphql \
-F owner="<OWNER>" \
-F repo="<REPO>" \
-F pr=<PR_NUMBER> \
${CURSOR:+-F cursor="$CURSOR"} \
-f 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
isOutdated
comments(first: 10) {
nodes { id databaseId body author { login } createdAt }
}
}
}
}
}
}')
echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> "$THREADS_FILE"
HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
[ "$HAS_NEXT" = "true" ] || break
CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
done
From these results, compute:
last_copilot_review_idfrom the latest review by:copilot-pull-request-reviewer[bot]copilot-pull-request-reviewerCopilot
last_copilot_review_commit_shafrom that same latest Copilot review (commit_id)current_pr_head_shafrom:gh pr view <PR_NUMBER> --json headRefOid -q .headRefOid
unresolved_copilot_thread_idsfrom threads where:isResolved == falseisOutdated == false- at least one comment author login is one of the Copilot logins above
outdated_copilot_thread_idsfrom threads where:isResolved == falseisOutdated == true- at least one comment author login is one of the Copilot logins above
comments(first: 10) is intentional to keep payload size small.
If Copilot participation is ambiguous for a thread, refetch that thread's
comments with a larger window before classifying it.
B. Bootstrap decision
- If Step H pushed code in the previous iteration, this takes priority: go to Step C regardless of other conditions.
- Otherwise, if
unresolved_copilot_thread_idsoroutdated_copilot_thread_idsis not empty, go to Step F to process them (skip reviewer request).- Process
unresolved_copilot_thread_idsfirst. - Then handle
outdated_copilot_thread_ids(resolve with rationale if superseded, or treat as actionable if still relevant).
- Process
- If there is no Copilot review yet, request Copilot review.
- Stop successfully only when:
unresolved_copilot_thread_idsis empty, andoutdated_copilot_thread_idsis empty, andcurrent_pr_head_sha == last_copilot_review_commit_sha.
C. Request Copilot review only when B says none exist
gh pr edit <PR_NUMBER> --add-reviewer "copilot-pull-request-reviewer"
If Copilot is already assigned and a fresh pass is needed:
gh pr edit <PR_NUMBER> --remove-reviewer "copilot-pull-request-reviewer"
gh pr edit <PR_NUMBER> --add-reviewer "copilot-pull-request-reviewer"
D. Wait for a new Copilot review (only when C ran)
Poll the reviews endpoint every 30 seconds until a new Copilot review appears
(review id differs from last_copilot_review_id captured in Step A).
Example:
LAST_ID="<LAST_COPILOT_REVIEW_ID_OR_0>"
for _ in {1..60}; do
LAST_LINE=$(gh api --paginate repos/<OWNER>/<REPO>/pulls/<PR_NUMBER>/reviews \
--jq '.[] | select(.user.login == "copilot-pull-request-reviewer[bot]" or .user.login == "copilot-pull-request-reviewer" or .user.login == "Copilot") | [.id, .commit_id] | @tsv' \
| tail -n 1)
NEW_ID=$(echo "$LAST_LINE" | cut -f1)
NEW_SHA=$(echo "$LAST_LINE" | cut -f2)
if [ -n "$NEW_ID" ] && [ "$NEW_ID" != "$LAST_ID" ]; then
break
fi
sleep 30
done
If no new Copilot review appears within 30 minutes, stop and report timeout. After new review appears, re-run Step A to refresh:
last_copilot_review_idlast_copilot_review_commit_shacurrent_pr_head_sha
Do not re-request Copilot again while waiting in this step.
E. Check exit conditions
Stop the loop if any condition is true:
- Latest Copilot review round is complete and:
unresolved_copilot_thread_idsis emptyoutdated_copilot_thread_idsis emptycurrent_pr_head_sha == last_copilot_review_commit_sha
- Max iterations reached (report remaining items).
Never treat "resolved by the agent" alone as terminal after a push; require a fresh Copilot review pass.
F. Process each Copilot thread
For each Copilot thread:
- Process
unresolved_copilot_thread_idsfirst, thenoutdated_copilot_thread_ids. - Read code context.
- Classify as
ActionableorNon-actionable. - If actionable, implement the fix and update tests/docs if needed.
- If non-actionable, prepare a short rationale reply.
- For outdated threads, explicitly decide and record:
superseded(reply with rationale and resolve), orstill relevant(treat as actionable).
G. Resolve and reply on threads
Fetch unresolved threads (all pages):
THREADS_FILE="$(mktemp)"
CURSOR=""
while true; do
PAGE=$(gh api graphql \
-F owner="<OWNER>" \
-F repo="<REPO>" \
-F pr=<PR_NUMBER> \
${CURSOR:+-F cursor="$CURSOR"} \
-f 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
isOutdated
comments(first: 10) {
nodes { id databaseId body author { login } }
}
}
}
}
}
}')
echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> "$THREADS_FILE"
HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
[ "$HAS_NEXT" = "true" ] || break
CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
done
comments(first: 10) is intentional here as well.
If you cannot find the needed Copilot comment databaseId for reply,
refetch that specific thread with a larger comments window before replying.
Resolve addressed threads:
for THREAD_ID in <THREAD_ID_1> <THREAD_ID_2> <THREAD_ID_N>; do
gh api graphql \
-F threadId="$THREAD_ID" \
-f query='
mutation($threadId: ID!) {
resolveReviewThread(input: {threadId: $threadId}) {
thread { isResolved }
}
}'
done
If batching in one mutation, generate one alias per thread id (t1..tN) dynamically.
Reply to non-actionable comments with rationale:
gh api repos/<OWNER>/<REPO>/pulls/<PR_NUMBER>/comments -f body='Rationale here' -F in_reply_to=<COMMENT_DATABASE_ID>
Use the comment databaseId from the GraphQL thread query for in_reply_to.
H. Commit and push once for the iteration
git add -- <CHANGED_FILE_1> <CHANGED_FILE_2> <CHANGED_FILE_N>
git status --short
git commit -m "agent: address copilot review feedback (gh-pilot iteration N)"
git push
Verify staged files before committing; do not include unrelated files.
After pushing code changes, go to Step C to request a fresh Copilot pass. If no code changes were made in this iteration, skip commit/push and return to Step A.
3. Report
At the end, summarize:
| Field | Value |
|---|---|
| Iterations | N |
| Copilot comments resolved | N |
| Copilot comments remaining | N |
| Final state | Success or max-iteration stop |
Output format
gh-pilot complete.
Iterations: N
Resolved: X comments
Remaining: Y
Final state: success|max-iteration-stop