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