From 70316f1a1771e1202e1e74c928d795506fd36e33 Mon Sep 17 00:00:00 2001 From: james-haytko_nwx Date: Tue, 24 Feb 2026 11:37:29 -0600 Subject: [PATCH] Switch reviewer to git diff-based inline suggestions Instead of asking Claude to output JSON, the reviewer now: - Has Claude edit files locally (no commit/push) - Posts a prose summary comment with all issues found - Runs a Python script that reads git diff HEAD to generate inline suggestions via the GitHub PR Reviews API This eliminates the JSON format reliability failures seen in previous runs and gives users Apply/Batch buttons on each fix. Generated with AI Co-Authored-By: Claude Code --- .../claude-documentation-reviewer.yml | 209 ++++++++++++++---- 1 file changed, 161 insertions(+), 48 deletions(-) diff --git a/.github/workflows/claude-documentation-reviewer.yml b/.github/workflows/claude-documentation-reviewer.yml index 4e3e92d956..391fbc3fa5 100644 --- a/.github/workflows/claude-documentation-reviewer.yml +++ b/.github/workflows/claude-documentation-reviewer.yml @@ -10,30 +10,13 @@ jobs: claude-response: runs-on: ubuntu-latest permissions: - contents: write + contents: read pull-requests: write issues: write id-token: write actions: read steps: - - name: Detect fork - id: pr-info - run: | - if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then - echo "is_fork=true" >> "$GITHUB_OUTPUT" - else - echo "is_fork=false" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout repository (non-fork) - if: steps.pr-info.outputs.is_fork == 'false' - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - - name: Checkout repository (fork) - if: steps.pr-info.outputs.is_fork == 'true' + - name: Checkout repository uses: actions/checkout@v4 with: # Check out by SHA to prevent TOCTOU attacks from forks. @@ -58,18 +41,28 @@ jobs: echo "count=$(echo "$CHANGED_MD_FILES" | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT" fi - - name: Delete existing review comment + - name: Delete existing review comments if: steps.changed-files.outputs.count > 0 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | PR_NUMBER=${{ github.event.pull_request.number }} + + # Delete existing prose summary comments COMMENT_IDS=$(gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \ --jq '[.[] | select(.user.login == "github-actions[bot]") | select(.body | startswith("## Documentation Review")) | .id] | .[]' 2>/dev/null || true) for ID in $COMMENT_IDS; do gh api repos/${{ github.repository }}/issues/comments/${ID} -X DELETE || true done + # Dismiss existing inline suggestion reviews + REVIEW_IDS=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}/reviews \ + --jq '[.[] | select(.user.login == "github-actions[bot]") | .id] | .[]' 2>/dev/null || true) + for ID in $REVIEW_IDS; do + gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}/reviews/${ID}/dismissals \ + -X PUT -f message="Superseded by new review" 2>/dev/null || true + done + - name: Checkout system prompt repository uses: actions/checkout@v4 with: @@ -91,8 +84,8 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" - - name: Review and fix (non-fork) - if: steps.changed-files.outputs.count > 0 && steps.pr-info.outputs.is_fork == 'false' + - name: Review and post issues + if: steps.changed-files.outputs.count > 0 uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -105,34 +98,154 @@ jobs: Do not review or comment on any other files. Focus exclusively on the documentation changes in the markdown files listed above. - 1. Fix all issues directly in the files using the Write and Edit tools. - 2. Commit and push the fixes using git. - 3. Post a PR comment starting with "## Documentation Review" that lists each issue that was fixed, following the format in your instructions. + 1. Fix all issues directly in the files using the Write and Edit tools. Do not commit or push. + 2. Post a PR comment starting with "## Documentation Review" that lists each issue found and fixed, following the format in your instructions. End the comment with a blank line followed by: "To apply all fixes at once, reply with `@claude apply all fixes`." claude_args: | --model claude-sonnet-4-5-20250929 - --allowedTools "Read,Write,Edit,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*),Bash(git config:*),Bash(git add:*),Bash(git commit:*),Bash(git push:*),Bash(git status:*),Bash(git diff:*)" + --allowedTools "Read,Write,Edit,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*)" --append-system-prompt "${{ steps.read-prompt.outputs.prompt }}" - - name: Review only (fork) - if: steps.changed-files.outputs.count > 0 && steps.pr-info.outputs.is_fork == 'true' - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - show_full_output: true - prompt: | - Review ONLY the following markdown files that were changed in this PR: ${{ steps.changed-files.outputs.files }} - - Use `gh pr diff ${{ github.event.pull_request.number }}` to see the exact changes made. - - Do not review or comment on any other files. Focus exclusively on the documentation changes in the markdown files listed above. - - Post a PR comment starting with "## Documentation Review" that lists all issues found, following the format in your instructions. - - This PR is from a fork. Do not attempt to edit files or push changes. The author must address the issues manually. - - claude_args: | - --model claude-sonnet-4-5-20250929 - --allowedTools "Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*)" - --append-system-prompt "${{ steps.read-prompt.outputs.prompt }}" + - name: Post inline suggestions + if: steps.changed-files.outputs.count > 0 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + python3 << 'PYTHON_EOF' + import subprocess + import json + import re + import os + import sys + + def parse_diff_to_suggestions(diff_text): + suggestions = [] + current_file = None + old_line_num = 0 + new_line_num = 0 + in_change = False + old_chunk = [] + new_chunk = [] + change_old_start = 0 + + for line in diff_text.split('\n'): + if line.startswith('diff --git'): + if in_change and current_file and old_chunk: + s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk) + if s: + suggestions.append(s) + in_change = False + old_chunk = [] + new_chunk = [] + current_file = None + elif line.startswith('+++ b/'): + current_file = line[6:] + elif line.startswith('@@'): + if in_change and current_file and old_chunk: + s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk) + if s: + suggestions.append(s) + in_change = False + old_chunk = [] + new_chunk = [] + match = re.match(r'@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@', line) + if match: + old_line_num = int(match.group(1)) + new_line_num = int(match.group(2)) + elif line.startswith('-') and not line.startswith('---'): + if not in_change: + in_change = True + change_old_start = old_line_num + old_chunk = [] + new_chunk = [] + old_chunk.append(line[1:]) + old_line_num += 1 + elif line.startswith('+') and not line.startswith('+++'): + if not in_change: + in_change = True + change_old_start = old_line_num + old_chunk = [] + new_chunk = [] + new_chunk.append(line[1:]) + new_line_num += 1 + else: + # Context line or blank + if in_change and current_file and old_chunk: + s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk) + if s: + suggestions.append(s) + in_change = False + old_chunk = [] + new_chunk = [] + if line.startswith(' '): + old_line_num += 1 + new_line_num += 1 + + # Flush final change + if in_change and current_file and old_chunk: + s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk) + if s: + suggestions.append(s) + + return suggestions + + def make_suggestion(path, old_start, old_chunk, new_chunk): + if not old_chunk: + return None # Pure insertions cannot be placed as inline suggestions + end_line = old_start + len(old_chunk) - 1 + suggestion_body = '```suggestion\n' + '\n'.join(new_chunk) + '\n```' + comment = { + 'path': path, + 'line': end_line, + 'side': 'RIGHT', + 'body': suggestion_body, + } + if len(old_chunk) > 1: + comment['start_line'] = old_start + comment['start_side'] = 'RIGHT' + return comment + + # Get diff of Claude's local edits vs HEAD + result = subprocess.run(['git', 'diff', 'HEAD'], capture_output=True, text=True) + diff_text = result.stdout + + if not diff_text.strip(): + print("No changes detected. Skipping inline suggestions.") + sys.exit(0) + + suggestions = parse_diff_to_suggestions(diff_text) + + if not suggestions: + print("No inline suggestions to post (changes may be pure insertions or deletions).") + sys.exit(0) + + print(f"Posting {len(suggestions)} inline suggestion(s)...") + + pr_number = os.environ['PR_NUMBER'] + head_sha = os.environ['HEAD_SHA'] + repo = os.environ['REPO'] + + review_payload = { + 'commit_id': head_sha, + 'body': '', + 'event': 'COMMENT', + 'comments': suggestions, + } + + result = subprocess.run( + ['gh', 'api', f'repos/{repo}/pulls/{pr_number}/reviews', + '-X', 'POST', '--input', '-'], + input=json.dumps(review_payload), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Error posting inline suggestions: {result.stderr}", file=sys.stderr) + sys.exit(1) + + print("Successfully posted inline suggestions.") + PYTHON_EOF