scripts/collect_pr_feedback.py
#!/usr/bin/env python3
"""Collect GitHub PR feedback and review-state reactions into local reports."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PR_URL_RE = re.compile(
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/pull/(?P<number>\d+)(?:[/?#].*)?$"
)
OUTSIDE_DIFF_RE = re.compile(
r"outside(?: the)? diff|outside diff range|comments outside diff|outside-diff",
re.IGNORECASE,
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Collect comments, reviews, threads, and review-state reactions for a GitHub PR."
)
parser.add_argument(
"target",
nargs="?",
help="PR URL or number. Defaults to the current branch PR.",
)
parser.add_argument(
"--repo",
help="OWNER/REPO when target is a number and the current directory is not the repository.",
)
parser.add_argument(
"--output-dir",
default=".tmp/pr-feedback",
help="Directory for generated report files. Defaults to .tmp/pr-feedback.",
)
args = parser.parse_args()
try:
owner, repo, number = resolve_pr(args.target, args.repo)
payload = collect_pr(owner, repo, number)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
stem = f"{owner}-{repo}-{number}"
json_path = output_dir / f"{stem}.json"
md_path = output_dir / f"{stem}.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
md_path.write_text(render_markdown(payload), encoding="utf-8")
except CommandError as error:
print(f"error: {error}", file=sys.stderr)
if error.stderr:
print(error.stderr.strip(), file=sys.stderr)
return 1
except Exception as error: # noqa: BLE001 - this is a user-facing CLI.
print(f"error: {error}", file=sys.stderr)
return 1
print(f"JSON: {json_path}")
print(f"Markdown: {md_path}")
return 0
class CommandError(RuntimeError):
def __init__(self, message: str, stderr: str = "") -> None:
super().__init__(message)
self.stderr = stderr
def resolve_pr(target: str | None, repo_arg: str | None) -> tuple[str, str, int]:
if target is None:
url = gh_text(["pr", "view", "--json", "url", "--jq", ".url"]).strip()
return parse_pr_url(url)
match = PR_URL_RE.match(target)
if match:
return match.group("owner"), match.group("repo"), int(match.group("number"))
if target.isdigit():
owner_repo = repo_arg or current_repo()
owner, repo = split_owner_repo(owner_repo)
return owner, repo, int(target)
url = gh_text(["pr", "view", target, "--json", "url", "--jq", ".url"]).strip()
return parse_pr_url(url)
def parse_pr_url(url: str) -> tuple[str, str, int]:
match = PR_URL_RE.match(url)
if not match:
raise ValueError(f"could not parse GitHub PR URL: {url}")
return match.group("owner"), match.group("repo"), int(match.group("number"))
def current_repo() -> str:
value = gh_text(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).strip()
if not value:
raise ValueError("could not infer current GitHub repository; pass --repo OWNER/REPO")
return value
def split_owner_repo(value: str) -> tuple[str, str]:
parts = value.split("/", 1)
if len(parts) != 2 or not all(parts):
raise ValueError(f"expected OWNER/REPO, got: {value}")
return parts[0], parts[1]
def collect_pr(owner: str, repo: str, number: int) -> dict[str, Any]:
base = f"repos/{owner}/{repo}"
pr = gh_json(["api", f"{base}/pulls/{number}"])
issue_comments = gh_json(["api", f"{base}/issues/{number}/comments", "--paginate"])
issue_comments = ensure_list(issue_comments)
collect_comment_reactions(base, issue_comments)
reviews = gh_json(["api", f"{base}/pulls/{number}/reviews", "--paginate"])
review_comments = gh_json(["api", f"{base}/pulls/{number}/comments", "--paginate"])
commits = gh_json(["api", f"{base}/pulls/{number}/commits", "--paginate"])
files = gh_json(["api", f"{base}/pulls/{number}/files", "--paginate"])
review_threads = collect_review_threads(owner, repo, number)
return {
"collected_at": datetime.now(timezone.utc).isoformat(),
"repository": f"{owner}/{repo}",
"number": number,
"pr": pr,
"pr_reactions": ensure_list(
gh_json(["api", f"{base}/issues/{number}/reactions", "--paginate"])
),
"issue_comments": issue_comments,
"reviews": ensure_list(reviews),
"review_comments": ensure_list(review_comments),
"review_threads": review_threads,
"commits": ensure_list(commits),
"files": ensure_list(files),
}
def collect_comment_reactions(base: str, comments: list[dict[str, Any]]) -> None:
for comment in comments:
comment_id = comment.get("id")
if not comment_id:
comment["reaction_details"] = []
continue
comment["reaction_details"] = ensure_list(
gh_json(["api", f"{base}/issues/comments/{comment_id}/reactions", "--paginate"])
)
def collect_review_threads(owner: str, repo: str, number: int) -> dict[str, Any]:
query = """
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
isOutdated
path
line
startLine
originalLine
originalStartLine
subjectType
comments(first: 100) {
nodes {
id
databaseId
url
body
createdAt
author {
login
}
}
}
}
}
}
}
}
"""
nodes: list[dict[str, Any]] = []
after: str | None = None
try:
while True:
command = [
"api",
"graphql",
"-f",
f"query={query}",
"-f",
f"owner={owner}",
"-f",
f"repo={repo}",
"-F",
f"number={number}",
]
if after:
command.extend(["-f", f"after={after}"])
data = gh_json(command)
errors = data.get("errors") or []
if errors:
return {"available": False, "nodes": [], "error": summarize_graphql_errors(errors)}
response_data = data.get("data") or {}
repository = response_data.get("repository") or {}
pull_request = repository.get("pullRequest") or {}
threads = pull_request.get("reviewThreads") or {}
nodes.extend(threads.get("nodes") or [])
page_info = threads.get("pageInfo") or {}
if not page_info.get("hasNextPage"):
break
after = page_info.get("endCursor")
if not after:
break
return {"available": True, "nodes": nodes, "error": None}
except CommandError as error:
return {"available": False, "nodes": [], "error": str(error)}
def summarize_graphql_errors(errors: list[dict[str, Any]]) -> str:
messages = [str(error.get("message") or "unknown GraphQL error") for error in errors]
return "; ".join(messages)
def gh_text(args: list[str]) -> str:
command = ["gh", *args]
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode != 0:
raise CommandError(f"command failed: {shell_join(command)}", result.stderr)
return result.stdout
def gh_json(args: list[str]) -> Any:
output = gh_text(args).strip()
if not output:
return None
try:
return json.loads(output)
except json.JSONDecodeError:
return parse_json_stream(output)
def parse_json_stream(output: str) -> list[Any]:
decoder = json.JSONDecoder()
index = 0
values: list[Any] = []
while index < len(output):
while index < len(output) and output[index].isspace():
index += 1
if index >= len(output):
break
value, index = decoder.raw_decode(output, index)
if isinstance(value, list):
values.extend(value)
else:
values.append(value)
return values
def ensure_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def render_markdown(payload: dict[str, Any]) -> str:
pr = payload["pr"]
lines = [
f"# PR Feedback Report: {payload['repository']}#{payload['number']}",
"",
f"- URL: {pr.get('html_url')}",
f"- Title: {pr.get('title')}",
f"- State: {pr.get('state')}",
f"- Base: {ref_name(pr.get('base'))}",
f"- Head: {ref_name(pr.get('head'))}",
f"- Latest head SHA: {sha(pr.get('head', {}).get('sha'))}",
f"- Collected at: {payload['collected_at']}",
"",
"## Counts",
"",
f"- Issue comments: {len(payload['issue_comments'])}",
f"- PR body reactions: {len(payload['pr_reactions'])}",
f"- Issue comment reactions: {sum(len(item.get('reaction_details') or []) for item in payload['issue_comments'])}",
f"- Reviews: {len(payload['reviews'])}",
f"- Review comments and replies: {len(payload['review_comments'])}",
f"- Review threads from GraphQL: {len(payload['review_threads'].get('nodes') or [])}"
if payload["review_threads"].get("available")
else f"- Review threads from GraphQL: unavailable ({payload['review_threads'].get('error')})",
f"- Commits: {len(payload['commits'])}",
f"- Files: {len(payload['files'])}",
"",
"The report intentionally does not dedupe findings. Verify each item",
"against current code, then group duplicates in your working ledger.",
"",
]
outside_sources = find_outside_diff_sources(payload)
lines.extend(["## Potential Outside-Diff Sources", ""])
if outside_sources:
for source in outside_sources:
lines.append(f"- {source}")
else:
lines.append("- None detected by keyword search.")
lines.append("")
lines.extend(render_review_state_reactions(payload))
lines.extend(render_pr_body(pr))
lines.extend(render_commits(payload["commits"]))
lines.extend(render_files(payload["files"]))
lines.extend(render_issue_comments(payload["issue_comments"]))
lines.extend(render_reviews(payload["reviews"]))
lines.extend(render_review_comments(payload["review_comments"]))
lines.extend(render_review_threads(payload["review_threads"]))
return "\n".join(lines).rstrip() + "\n"
def render_review_state_reactions(payload: dict[str, Any]) -> list[str]:
lines = ["## Review State Reactions", ""]
reactions: list[tuple[str, str | None, dict[str, Any]]] = [
("PR body", payload["pr"].get("html_url"), reaction)
for reaction in payload["pr_reactions"]
]
for comment in payload["issue_comments"]:
target = f"Issue comment {comment.get('id')} by {author(comment)}"
reactions.extend(
(target, comment.get("html_url"), reaction)
for reaction in comment.get("reaction_details") or []
)
if not reactions:
return lines + ["_No PR body or issue comment reactions._", ""]
for target, url, reaction in reactions:
content = reaction.get("content") or "unknown"
lines.append(
f"- {reaction_symbol(content)} `{content}` by {author(reaction)} on {target} "
f"at {reaction.get('created_at') or 'unknown time'}: {url or 'URL unavailable'}"
)
lines.append("")
return lines
def render_pr_body(pr: dict[str, Any]) -> list[str]:
return [
"## PR Body",
"",
pr.get("body") or "_No PR body._",
"",
]
def render_commits(commits: list[dict[str, Any]]) -> list[str]:
lines = ["## Commits", ""]
if not commits:
return lines + ["- None collected.", ""]
for commit in commits:
data = commit.get("commit") or {}
message = (data.get("message") or "").splitlines()[0]
lines.append(f"- `{sha(commit.get('sha'))}` {message}")
lines.append("")
return lines
def render_files(files: list[dict[str, Any]]) -> list[str]:
lines = ["## Files", ""]
if not files:
return lines + ["- None collected.", ""]
for item in files:
lines.append(
f"- `{item.get('filename')}` {item.get('status')} (+{item.get('additions')}/-{item.get('deletions')})"
)
lines.append("")
return lines
def render_issue_comments(comments: list[dict[str, Any]]) -> list[str]:
lines = ["## Issue Comments", ""]
if not comments:
return lines + ["_No issue comments._", ""]
for comment in comments:
lines.extend(
render_body_block(
title=f"Issue comment {comment.get('id')} by {author(comment)}",
url=comment.get("html_url"),
created_at=comment.get("created_at"),
body=comment.get("body") or "",
)
)
return lines
def render_reviews(reviews: list[dict[str, Any]]) -> list[str]:
lines = ["## Review Bodies", ""]
if not reviews:
return lines + ["_No reviews._", ""]
for review in reviews:
title = (
f"Review {review.get('id')} by {author(review)} "
f"({review.get('state')}, commit {sha(review.get('commit_id'))})"
)
lines.extend(
render_body_block(
title=title,
url=review.get("html_url"),
created_at=review.get("submitted_at"),
body=review.get("body") or "",
)
)
return lines
def render_review_comments(comments: list[dict[str, Any]]) -> list[str]:
lines = ["## Review Comments And Replies", ""]
if not comments:
return lines + ["_No review comments._", ""]
for comment in comments:
reply_to = comment.get("in_reply_to_id")
relation = f", reply to {reply_to}" if reply_to else ""
title = (
f"Review comment {comment.get('id')} by {author(comment)}"
f"{relation} on `{comment.get('path')}`"
)
line_bits = [
value
for value in [
f"line {comment.get('line')}" if comment.get("line") else None,
f"original line {comment.get('original_line')}"
if comment.get("original_line")
else None,
f"subject {comment.get('subject_type')}" if comment.get("subject_type") else None,
]
if value
]
lines.extend(
render_body_block(
title=title,
url=comment.get("html_url"),
created_at=comment.get("created_at"),
body=comment.get("body") or "",
extra=", ".join(line_bits),
)
)
return lines
def render_review_threads(review_threads: dict[str, Any]) -> list[str]:
lines = ["## Review Threads", ""]
if not review_threads.get("available"):
return lines + [f"_Unavailable: {review_threads.get('error')}_", ""]
nodes = review_threads.get("nodes") or []
if not nodes:
return lines + ["_No review threads._", ""]
for thread in nodes:
status = "resolved" if thread.get("isResolved") else "unresolved"
outdated = ", outdated" if thread.get("isOutdated") else ""
lines.append(
f"### Thread {thread.get('id')} ({status}{outdated}) on `{thread.get('path')}`"
)
lines.append("")
for comment in (thread.get("comments") or {}).get("nodes") or []:
lines.append(
f"- Comment databaseId={comment.get('databaseId')} by {nested_author(comment)} at {comment.get('createdAt')}: {comment.get('url')}"
)
lines.append("")
return lines
def render_body_block(
*,
title: str,
url: str | None,
created_at: str | None,
body: str,
extra: str = "",
) -> list[str]:
lines = [f"### {title}", ""]
metadata = [f"URL: {url}" if url else None, f"Created: {created_at}" if created_at else None, extra]
lines.extend([item for item in metadata if item])
lines.append("")
lines.append(body or "_No body._")
lines.extend(["", "---", ""])
return lines
def find_outside_diff_sources(payload: dict[str, Any]) -> list[str]:
sources: list[str] = []
for review in payload["reviews"]:
if OUTSIDE_DIFF_RE.search(review.get("body") or ""):
sources.append(f"review {review.get('id')} by {author(review)}: {review.get('html_url')}")
for comment in payload["issue_comments"]:
if OUTSIDE_DIFF_RE.search(comment.get("body") or ""):
sources.append(f"issue comment {comment.get('id')} by {author(comment)}: {comment.get('html_url')}")
for comment in payload["review_comments"]:
if OUTSIDE_DIFF_RE.search(comment.get("body") or ""):
sources.append(
f"review comment {comment.get('id')} by {author(comment)}: {comment.get('html_url')}"
)
return sources
def author(item: dict[str, Any]) -> str:
user = item.get("user") or {}
return user.get("login") or "unknown"
def nested_author(item: dict[str, Any]) -> str:
user = item.get("author") or {}
return user.get("login") or "unknown"
def reaction_symbol(content: str) -> str:
return {
"+1": "👍",
"-1": "👎",
"laugh": "😄",
"confused": "😕",
"heart": "❤️",
"hooray": "🎉",
"rocket": "🚀",
"eyes": "👀",
}.get(content, "❔")
def ref_name(ref: dict[str, Any] | None) -> str:
if not ref:
return "unknown"
label = ref.get("label")
ref_value = ref.get("ref")
return label or ref_value or "unknown"
def sha(value: str | None) -> str:
if not value:
return "unknown"
return value[:12]
def shell_join(command: list[str]) -> str:
return " ".join(sh_quote(part) for part in command)
def sh_quote(value: str) -> str:
if re.fullmatch(r"[A-Za-z0-9_./:=@%+-]+", value):
return value
return "'" + value.replace("'", "'\"'\"'") + "'"
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md
---
name: address-pr-feedback
description: "Address existing GitHub PR feedback from human or bot reviewers end to end, including stacked PR chains."
---
# Address PR Feedback
Handle an existing GitHub PR from review intake through local fixes, validation,
push, and reviewer-facing replies. Treat PR feedback as broader than inline
threads: review bodies, issue comments, bot summaries, "outside diff" sections,
and follow-up comments can all contain actionable items.
If the task covers multiple dependent or stacked PRs, read
[`workflows/stacked-prs.md`](workflows/stacked-prs.md) before mapping,
retargeting, or changing any PR branch in the stack. Select its feedback-only or
authorized landing route, then apply the intake, fix, reply, and per-PR
completion checks below to each current PR. Defer the
user-facing completion response until the entire selected stack satisfies the
workflow's completion gate.
## Intake
1. Start from a clean understanding of the PR.
- Identify the PR URL/number, repository, base branch, head branch, author,
review state, and latest head commit.
- If no PR is specified, infer the current branch PR with `gh pr view`.
- Do not overwrite unrelated local changes. If the working tree is dirty,
separate user changes from PR-fix changes before editing.
2. Collect the complete PR feedback surface.
- Prefer the bundled `scripts/collect_pr_feedback.py` collector:
```bash
python3 <skill-dir>/scripts/collect_pr_feedback.py <pr-url-or-number>
```
- Read the generated Markdown report first, then inspect the JSON when
thread metadata, reply structure, or comment IDs are needed.
- If the collector cannot run, manually gather the same surfaces with `gh`:
PR body, PR-body and issue-comment reactions with actor identities, issue
comments, review bodies, review comments, commits, files, and review-thread
resolution status when available.
- Check the report's `Potential Outside-Diff Sources` section, then search
the collected artifacts case-insensitively for `outside diff`,
`outside the diff`, `Actionable comments`, `Nitpick comments`,
`Prompt for all review comments`, and bot names.
3. Gate action on active Codex and CodeRabbit reviews.
- Treat manual review requests as one-time PR-level gates, not per-push
gates. Never trigger either service for incremental follow-up pushes made
while addressing feedback. If a review starts automatically, wait for it
and handle its findings; otherwise continue with the feedback already
collected.
- Determine each service's state for the latest head commit from the freshly
collected reactions, trigger comments, status comments, reviews, and final
summaries. Inspect PR checks or status contexts when those artifacts do
not make the current state clear.
- Count only reactions authored by the Codex connector account
(`chatgpt-codex-connector[bot]`) or a verified replacement identity.
- Treat 👀 (`eyes`) on the PR body or an `@codex review` comment as accepted
or in-progress review evidence.
- Treat 👍 (`+1`) as a completed Codex review with no findings, even when
Codex posted no review body or inline comment. Record the reaction target,
actor, and timestamp as completion evidence and do not retrigger.
- Treat a Codex-authored review with findings as completed review evidence.
- Treat a CodeRabbit processing or status message that says a review is
underway as in-progress evidence; treat its completed review or final
summary as completed evidence.
- Reactions from other actors and aggregate reaction counts without actor
identities do not establish Codex state.
- While either service has an active review, wait and rerun the collector at
a reasonable interval. Begin assessment, ledger creation, checkout, edits,
pushes, and replies only after every observed active review completes and
the feedback surface has been refreshed. An active signal consumes the
current request; wait instead of triggering the service again.
- Keep intake blocked when an active review explicitly fails or stalls beyond
a reasonable task wait window; diagnose and report the review failure. If
neither service has an active review, proceed with the feedback already
available; this gate does not require triggering an absent review.
4. Critically assess each finding before planning fixes.
- Treat suggested patches from CodeRabbit, other bots, or reviewers as
proposals, not instructions; do not apply them blindly.
- Decide whether the proper action is no-op with evidence, a narrow fix,
added validation, or a larger refactor that addresses an underlying code
smell or design flaw.
- Challenge review claims against current code, requirements, and PR intent.
Mark incorrect, stale, duplicate, or harmful suggestions explicitly in the
ledger.
5. Build a concise feedback ledger before editing.
- Track every actionable item with source, URL or comment ID, path/line when
available, current status, planned handling, and eventual reply target.
- Include actionable findings embedded in review bodies or bot summary
comments, even when there is no separate GitHub comment to resolve.
- Mark duplicate findings together, but keep all source links so replies can
acknowledge every place the issue was raised.
- Verify each item against current code before changing anything. Skip
stale or already-fixed items with a brief reason.
## Local Fix Loop
1. Check out the PR branch with `gh pr checkout <pr-url-or-number>` or the
repository's existing branch workflow.
2. Fetch and confirm the local branch matches the remote head before editing;
if the remote has new commits, reconcile and re-check the feedback ledger
first.
3. Fix feedback in small cohesive groups.
- Commit as progress is made, one commit per feedback cluster or subsystem.
- Keep each commit message specific enough to map back to handled feedback.
4. After each meaningful fix group, run targeted validation for the touched
code. Keep expanding validation only when the blast radius warrants it.
5. Run a local implementation review pass over the follow-up diff before
pushing.
- Check correctness, regressions, validation gaps, edge cases, tests,
contracts, and avoidable complexity introduced by the fixes.
- Apply obvious safe fixes locally and commit them in the relevant group or
in a small follow-up cleanup commit.
- Leave broader design decisions for the user unless the PR feedback
clearly requires them.
6. Push the branch after local validation passes or after a completed bounded
group when the task is long-running and remote visibility matters. Refresh
the feedback surface after each push and re-enter the intake review gate if
Codex or CodeRabbit starts another review.
## Reply Workflow
1. Reply only after the relevant commit is pushed.
2. For inline review comments, reply to the thread with the GitHub review
comment reply endpoint, for example:
```bash
gh api -X POST repos/OWNER/REPO/pulls/PR_NUMBER/comments/COMMENT_ID/replies \
-f body="$(cat /tmp/reply.md)"
```
3. For PR-level comments, review-body findings, bot summary findings, and
outside-diff items without a resolvable thread, add one concise PR comment
that lists:
- commit hash or hashes pushed;
- handled items grouped by source;
- validation run and any blocked validation;
- items intentionally skipped as stale, duplicate, or not applicable.
4. Do not mention skill names, local automation internals, or implementation
process details in PR comments. Write as the PR author/operator explaining
what changed and how it was validated.
5. Prefer concrete replies over vague closure language:
- `Addressed in abc1234: dry-run now performs the same conflict check as write mode, with focused CLI coverage.`
- `Verified current code already has a separate health-check timeout in def5678, so this thread is stale after the latest push.`
6. If a finding is rejected, explain the current code evidence and tradeoff
briefly. Do not argue with bot style comments; keep the reply factual.
## Completion Check
Before finishing, confirm:
- no Codex or CodeRabbit review remains active, and the feedback surface was
refreshed after the latest review completed;
- all PR-body and issue-comment reactions, issue comments, review bodies, review
comments, replies, and outside-diff sections were read;
- each actionable item is fixed, replied to, explicitly skipped, or left as a
user decision;
- every follow-up commit is pushed;
- local validation and local review results are captured in the final PR
comment or thread replies;
- the final response to the user includes pushed commit hashes, validation, and
any remaining PR feedback that needs human judgment.
workflows/stacked-prs.md
# Stacked Pull Requests
Use this branch when multiple open PRs form a linear base/head chain. Process
the selected chain from upstream to downstream within the requested scope.
## Select the scope
A request to address feedback across a stack authorizes the parent skill's
feedback workflow for each selected PR. Map the stack using **Map the Stack**,
preserve its base/head relationships, and apply the parent intake, fix, reply,
and completion checks upstream to downstream. If a fix requires changing the
stack topology or integrating a dependency beyond the authorized scope, prepare
the evidence and ask for that decision. Finish when every selected PR meets the
parent completion checks or report its concrete blocker.
Use the staging and landing procedure below only when merging the selected PRs
is already authorized. Addressing feedback alone does not authorize retargeting,
history rewrites, or merging. The landing route continues until every selected
PR is merged into staging or a PR reaches a concrete blocker.
## Landing invariants
- Work on one PR at a time. Merge it before preparing its downstream PR.
- Put the stack behind a staging branch before merging the first PR when its
base is the repository's default or protected integration branch.
- When `dev` is used as an alternative integration base to `main`, fetch both
remote branches and verify that `origin/dev` and `origin/main` resolve to the
same commit. If the tips differ, stop and report that `dev` must be
synchronized or that the stack needs an explicitly chosen base. Do not
update, merge, or rewrite `dev` without separate authorization.
- Leave the completed staging branch for the user. Never merge it into the
original base or delete it as part of this workflow.
- Avoid explicitly deleting stack head branches. Record immutable head SHAs and
downstream fork points so repository-level branch auto-deletion cannot break
the next integration.
- Spend at most one manual CodeRabbit trigger and one manual Codex trigger per
PR. An existing completed or in-progress review counts. Never retrigger after
an incremental push.
- Treat automatically posted follow-up feedback as feedback to address, even
though the agent did not trigger another review.
- Stop at the current PR when a required review cannot run, a required check
fails, the PR is not mergeable, permissions are insufficient, or the stack
topology is ambiguous. Leave downstream PRs untouched and report the exact
blocker.
## Map the Stack
1. List open PRs and capture at least `number`, `url`, `baseRefName`,
`headRefName`, `headRefOid`, `isDraft`, and merge/check state. Use an
explicitly supplied PR list when the user provided one; otherwise traverse
open PRs whose base branch is another PR's head branch.
2. Build the base-to-head graph and identify the selected linear chain. The
most upstream PR is the node whose base is not another selected PR's head.
3. Confirm that every selected PR has exactly one predecessor and one successor
at most. Ask the user to choose a path when the graph forks or when unrelated
open PRs target a stack branch.
4. Fetch the selected heads and record each successor's dependency fork point
as the merge base between its head and its predecessor's current head. Do
this before any stack head is rebased or otherwise rewritten.
5. Create a stack ledger containing the original base and SHA, staging branch,
ordered PRs, original base/head pairs, current head SHA, dependency fork
points, review evidence, trigger comment IDs, validation/check state, and
eventual merge commit.
The map is complete when every selected PR appears exactly once in upstream-to-
downstream order and no dependency edge is inferred only from PR titles or
branch-name resemblance.
## Establish the Staging Base
When the upstream PR targets the original default or protected integration
branch:
1. Reuse a user-specified staging branch. Otherwise choose a collision-resistant
name such as `staging/pr-stack-<upstream-pr-number>`.
2. Create the staging ref at the upstream PR's original base SHA. If that name
already exists, reuse it only when its purpose and current SHA are compatible;
otherwise choose a new name. For a new ref, use:
```bash
gh api --method POST repos/{owner}/{repo}/git/refs \
-f ref="refs/heads/$staging_branch" \
-f sha="$original_base_sha"
```
3. Retarget only the upstream PR to staging with
`gh pr edit <pr> --base <staging-branch>`.
4. Verify that staging still points at the recorded original base SHA and that
the upstream PR's effective patch is unchanged.
If the upstream PR already targets a deliberate staging branch, record and use
that branch after verifying its relationship to the intended original base.
The staging gate is complete only when the first PR can no longer merge directly
into the original base and its patch remains the intended one.
## Process Each PR
Repeat this section for every ledger entry. Do not start its successor early.
### 1. Prepare the branch
For the first PR, use the staging retarget above. For each later PR, only after
its predecessor is merged:
1. Before rewriting the current PR head, refresh its successor when one exists
and update that successor's fork point against the current pre-integration
head.
2. Record the PR's old base, then retarget it to the staging branch.
3. Fetch the staging branch and PR head. Use the dependency fork point recorded
before the predecessor merged; do not assume its branch ref still exists.
4. Integrate the updated staging tip into the PR head using the repository's
established branch policy. Prefer rebasing the downstream-only commits onto
staging from the recorded fork point when history rewrites are allowed;
otherwise merge staging into the head. Use `--force-with-lease` for a
rewritten head.
5. Resolve conflicts in favor of the combined upstream fixes plus the PR's own
intent, then run validation appropriate to the integration.
6. Push and verify that the PR diff against staging contains its downstream
change only, while its working tree contains every merged upstream fix.
Preparation is complete when the PR targets staging, includes the current
staging tip, and no already-merged upstream patch appears as an accidental new
change in its diff.
### 2. Ensure one review from each service
Run the parent skill's feedback collection and active-review gate to classify
existing CodeRabbit and Codex evidence for the current head SHA before posting
anything. The parent intake rules are the source of truth for bot identities,
reaction meanings, and completed versus in-progress state.
- Require one completed review from each service for the current PR. A
completed no-findings review satisfies this requirement.
- If CodeRabbit has neither completed or in-progress evidence nor a prior
trigger, post exactly one `@coderabbitai full review` PR comment.
- If Codex has neither completed or in-progress evidence nor a prior trigger,
post exactly one `@codex review` PR comment. Confirm the exact trigger against
the OpenAI Codex GitHub review documentation when needed.
- Treat a previous trigger without a completed review as the request to wait
for or diagnose; it does not authorize another trigger.
Wait until both reviews complete or explicitly fail. When a service is not
installed, does not react, reports an authorization/configuration error, or
never completes within the task's reasonable wait window, block the current PR
instead of merging without that review.
### 3. Address the complete feedback surface
After both required reviews are available, complete the parent skill's intake
by assessing every finding and building the feedback ledger, then run its local
fix loop and reply workflow for the current PR. Include all human feedback and
all bot feedback, not only CodeRabbit and Codex findings.
After each push, refresh comments, reviews, threads, and checks. Add any
automatically created findings to the ledger and address them, but preserve the
one-trigger budget. This step is complete only when every actionable item on the
current PR is fixed and replied to, explicitly rejected with evidence, or
escalated for user judgment.
### 4. Merge the current PR
Refresh the PR immediately before merging. Require all of the following:
- the PR still targets the staging branch and contains the expected patch;
- completed CodeRabbit and Codex review evidence is recorded;
- the feedback ledger has no unhandled actionable item;
- required checks and local validation pass;
- the PR is ready, mergeable, and free of an unmet required approval; and
- every follow-up commit and reviewer reply is pushed.
Use the repository's required merge method. Keep the head branch while a
downstream PR still depends on it when repository settings allow. Before
merging a PR with a successor, verify the successor's recorded fork point is
still an ancestor of its head. Keep that pre-rewrite boundary; recomputing it
against a rewritten predecessor can replay already-merged commits. Verify the
current PR reached `MERGED`, record its merge commit and the new staging tip,
then begin preparing the next ledger entry.
## Landing completion
Finish only when every selected PR is merged into staging. Report:
- the original base and the staging branch left for the user;
- each PR in order, with review evidence or the single trigger comment for each
service, pushed fix commits, validation, and merge commit;
- the final staging tip and its diff/check state relative to the original base;
- any automatically generated feedback handled after incremental pushes; and
- the explicit handoff that integrating staging into the original base remains
a user-controlled action.