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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,41 @@ Questions published before the `<details>` shape existed fall back to a single-l
A question that is already open is never re-asked; the original stays the copy the author
answers.

### Comment-triggered reconciliation (opt-in)

By default the review only runs on `pull_request` events, so an author who answers an open
question in a PR comment sees nothing happen until the next push. A consumer can also let a
comment drive a reconcile round by adding an `issue_comment` trigger:

```yaml
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
issue_comment:
types: [created]

jobs:
pr-review:
# Only human comments on a PR; ignore issue comments and bot chatter.
if: >-
github.event_name != 'issue_comment' ||
(github.event.issue.pull_request != null &&
github.event.comment.user.type != 'Bot')
concurrency:
# issue_comment payloads carry issue.number, not pull_request.number.
group: claude-pr-review-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
```

The action gates the round cheaply so routine chatter does not spend a review: on an
`issue_comment` event, `prepare` skips unless the PR still has an **open question or open
finding** in the manifest (something a comment could answer, justify, or invalidate). When it
does run it is an incremental round that reuses the same sticky-comment manifest — so the
reviewer keeps its full prior context, unlike a fresh `@claude` session — and it runs on the
cheaper incremental model tier. If the comment turns out not to change anything, the publisher
posts nothing (no new review, no notification). A comment with no new commit reconciles the
discussion against the existing head; a comment that races a push reviews the new delta too.

## Per-Repo Conventions

The prompt-bearing actions instruct Claude to read and respect a consumer repo's own agent
Expand Down
3 changes: 2 additions & 1 deletion .github/actions/claude-pr-review/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ runs:
set -euo pipefail
python3 "$GITHUB_ACTION_PATH/review_pipeline.py" prepare \
--repository "$GITHUB_REPOSITORY" \
--pull-request "${{ github.event.pull_request.number }}" \
--pull-request "${{ github.event.pull_request.number || github.event.issue.number }}" \
--event-head "${{ github.event.pull_request.head.sha }}" \
--event-base "${{ github.event.pull_request.base.sha }}" \
--event-name "$GITHUB_EVENT_NAME" \
--state-dir "$STATE_DIR" \
--resolve-threads "${{ inputs.github_identity_token != '' }}" \
--review-depth "$REVIEW_DEPTH" \
Expand Down
57 changes: 52 additions & 5 deletions .github/actions/claude-pr-review/review_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,29 @@ def write_github_output(values: dict[str, Any]) -> None:
output.write(f"{key}={value}\n")


def manifest_has_open_items(manifest: dict[str, Any]) -> bool:
"""True when the manifest still has an open question or open finding.

Gates comment-triggered rounds: a PR comment only warrants a new review
round when the reviewer is still waiting on something the comment might
answer, justify, or invalidate. Findings count too, not just questions —
a comment can push back on a finding, not only answer a question.
"""
findings = manifest.get("findings")
if isinstance(findings, dict) and any(
isinstance(finding, dict) and finding.get("status") == "open"
for finding in findings.values()
):
return True
questions = manifest.get("questions")
if isinstance(questions, dict) and any(
isinstance(question, dict) and question.get("status") == "open"
for question in questions.values()
):
return True
return False


def prepare(args: argparse.Namespace) -> None:
action_dir = Path(__file__).resolve().parent
state_dir = Path(args.state_dir)
Expand Down Expand Up @@ -1163,16 +1186,39 @@ def prepare(args: argparse.Namespace) -> None:
comparison.get("files", []) if isinstance(comparison, dict) else []
)
comparison_complete = len(comparison_files) < 300
if previous_head == current_head and version_matches:
mode = "skip"
mode_reason = "current head already reviewed"
elif (
ancestor_cursor = bool(
previous_head
and version_matches
and comparison
and comparison.get("status") in {"ahead", "identical"}
and comparison_complete
):
)
if args.event_name == "issue_comment":
# A comment never adds code to review — it can only answer an open
# question or justify/invalidate an open finding. Run a reconcile-only
# incremental round when the reviewer is still waiting on something and
# the prior manifest is usable; otherwise skip, so routine chatter does
# not spend a review. When the head has not moved the delta is empty and
# the round exists purely to reconcile the new discussion.
if (
version_matches
and manifest_has_open_items(manifest)
and (previous_head == current_head or ancestor_cursor)
):
mode = "incremental"
mode_reason = "comment received while items are open"
incremental_paths = [
str(file["filename"])
for file in comparison_files
if isinstance(file, dict) and file.get("filename")
]
else:
mode = "skip"
mode_reason = "comment received with nothing open to reconcile"
elif previous_head == current_head and version_matches:
mode = "skip"
mode_reason = "current head already reviewed"
elif ancestor_cursor:
mode = "incremental"
mode_reason = "valid prior manifest and ancestor review cursor"
incremental_paths = [
Expand Down Expand Up @@ -2970,6 +3016,7 @@ def parser() -> argparse.ArgumentParser:
prepare_parser.add_argument("--pull-request", type=int, required=True)
prepare_parser.add_argument("--event-head")
prepare_parser.add_argument("--event-base")
prepare_parser.add_argument("--event-name", default="pull_request")
prepare_parser.add_argument("--state-dir", required=True)
prepare_parser.add_argument(
"--resolve-threads",
Expand Down
32 changes: 32 additions & 0 deletions .github/actions/claude-pr-review/test_review_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,38 @@ def test_action_credits_human_answers_to_open_questions(self):
action,
)

def test_manifest_has_open_items_gates_comment_rounds(self):
# The gate that decides whether a PR comment is worth a review round:
# true only while a question or finding is still open.
self.assertFalse(pipeline.manifest_has_open_items({}))
self.assertFalse(
pipeline.manifest_has_open_items(
{
"findings": {"F-1": {"status": "resolved"}},
"questions": {"Q-1": {"status": "answered"}},
}
)
)
self.assertTrue(
pipeline.manifest_has_open_items(
{"findings": {"F-1": {"status": "open"}}, "questions": {}}
)
)
self.assertTrue(
pipeline.manifest_has_open_items(
{"findings": {}, "questions": {"Q-1": {"status": "open"}}}
)
)

def test_action_wires_comment_trigger_inputs(self):
action = Path(pipeline.__file__).with_name("action.yml").read_text(
encoding="utf-8"
)
# On an issue_comment event the PR number falls back to the issue
# number, and the event name is passed so prepare can gate the round.
self.assertIn("github.event.issue.number", action)
self.assertIn("--event-name", action)

def test_action_exposes_optional_github_identity_token(self):
action = Path(pipeline.__file__).with_name("action.yml").read_text(
encoding="utf-8"
Expand Down
Loading