Skip to content

Commit c48f1c2

Browse files
authored
Merge pull request #299 from netwrix/dev
Switch reviewer to git diff-based inline suggestions
2 parents 30ad226 + 70316f1 commit c48f1c2

1 file changed

Lines changed: 161 additions & 48 deletions

File tree

.github/workflows/claude-documentation-reviewer.yml

Lines changed: 161 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,13 @@ jobs:
1010
claude-response:
1111
runs-on: ubuntu-latest
1212
permissions:
13-
contents: write
13+
contents: read
1414
pull-requests: write
1515
issues: write
1616
id-token: write
1717
actions: read
1818
steps:
19-
- name: Detect fork
20-
id: pr-info
21-
run: |
22-
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
23-
echo "is_fork=true" >> "$GITHUB_OUTPUT"
24-
else
25-
echo "is_fork=false" >> "$GITHUB_OUTPUT"
26-
fi
27-
28-
- name: Checkout repository (non-fork)
29-
if: steps.pr-info.outputs.is_fork == 'false'
30-
uses: actions/checkout@v4
31-
with:
32-
ref: ${{ github.event.pull_request.head.ref }}
33-
fetch-depth: 0
34-
35-
- name: Checkout repository (fork)
36-
if: steps.pr-info.outputs.is_fork == 'true'
19+
- name: Checkout repository
3720
uses: actions/checkout@v4
3821
with:
3922
# Check out by SHA to prevent TOCTOU attacks from forks.
@@ -58,18 +41,28 @@ jobs:
5841
echo "count=$(echo "$CHANGED_MD_FILES" | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT"
5942
fi
6043
61-
- name: Delete existing review comment
44+
- name: Delete existing review comments
6245
if: steps.changed-files.outputs.count > 0
6346
env:
6447
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6548
run: |
6649
PR_NUMBER=${{ github.event.pull_request.number }}
50+
51+
# Delete existing prose summary comments
6752
COMMENT_IDS=$(gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
6853
--jq '[.[] | select(.user.login == "github-actions[bot]") | select(.body | startswith("## Documentation Review")) | .id] | .[]' 2>/dev/null || true)
6954
for ID in $COMMENT_IDS; do
7055
gh api repos/${{ github.repository }}/issues/comments/${ID} -X DELETE || true
7156
done
7257
58+
# Dismiss existing inline suggestion reviews
59+
REVIEW_IDS=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}/reviews \
60+
--jq '[.[] | select(.user.login == "github-actions[bot]") | .id] | .[]' 2>/dev/null || true)
61+
for ID in $REVIEW_IDS; do
62+
gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}/reviews/${ID}/dismissals \
63+
-X PUT -f message="Superseded by new review" 2>/dev/null || true
64+
done
65+
7366
- name: Checkout system prompt repository
7467
uses: actions/checkout@v4
7568
with:
@@ -91,8 +84,8 @@ jobs:
9184
echo "EOF"
9285
} >> "$GITHUB_OUTPUT"
9386
94-
- name: Review and fix (non-fork)
95-
if: steps.changed-files.outputs.count > 0 && steps.pr-info.outputs.is_fork == 'false'
87+
- name: Review and post issues
88+
if: steps.changed-files.outputs.count > 0
9689
uses: anthropics/claude-code-action@v1
9790
with:
9891
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -105,34 +98,154 @@ jobs:
10598
10699
Do not review or comment on any other files. Focus exclusively on the documentation changes in the markdown files listed above.
107100
108-
1. Fix all issues directly in the files using the Write and Edit tools.
109-
2. Commit and push the fixes using git.
110-
3. Post a PR comment starting with "## Documentation Review" that lists each issue that was fixed, following the format in your instructions.
101+
1. Fix all issues directly in the files using the Write and Edit tools. Do not commit or push.
102+
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`."
111103
112104
claude_args: |
113105
--model claude-sonnet-4-5-20250929
114-
--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:*)"
106+
--allowedTools "Read,Write,Edit,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*)"
115107
--append-system-prompt "${{ steps.read-prompt.outputs.prompt }}"
116108
117-
- name: Review only (fork)
118-
if: steps.changed-files.outputs.count > 0 && steps.pr-info.outputs.is_fork == 'true'
119-
uses: anthropics/claude-code-action@v1
120-
with:
121-
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
122-
github_token: ${{ secrets.GITHUB_TOKEN }}
123-
show_full_output: true
124-
prompt: |
125-
Review ONLY the following markdown files that were changed in this PR: ${{ steps.changed-files.outputs.files }}
126-
127-
Use `gh pr diff ${{ github.event.pull_request.number }}` to see the exact changes made.
128-
129-
Do not review or comment on any other files. Focus exclusively on the documentation changes in the markdown files listed above.
130-
131-
Post a PR comment starting with "## Documentation Review" that lists all issues found, following the format in your instructions.
132-
133-
This PR is from a fork. Do not attempt to edit files or push changes. The author must address the issues manually.
134-
135-
claude_args: |
136-
--model claude-sonnet-4-5-20250929
137-
--allowedTools "Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*)"
138-
--append-system-prompt "${{ steps.read-prompt.outputs.prompt }}"
109+
- name: Post inline suggestions
110+
if: steps.changed-files.outputs.count > 0
111+
env:
112+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
113+
PR_NUMBER: ${{ github.event.pull_request.number }}
114+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
115+
REPO: ${{ github.repository }}
116+
run: |
117+
python3 << 'PYTHON_EOF'
118+
import subprocess
119+
import json
120+
import re
121+
import os
122+
import sys
123+
124+
def parse_diff_to_suggestions(diff_text):
125+
suggestions = []
126+
current_file = None
127+
old_line_num = 0
128+
new_line_num = 0
129+
in_change = False
130+
old_chunk = []
131+
new_chunk = []
132+
change_old_start = 0
133+
134+
for line in diff_text.split('\n'):
135+
if line.startswith('diff --git'):
136+
if in_change and current_file and old_chunk:
137+
s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk)
138+
if s:
139+
suggestions.append(s)
140+
in_change = False
141+
old_chunk = []
142+
new_chunk = []
143+
current_file = None
144+
elif line.startswith('+++ b/'):
145+
current_file = line[6:]
146+
elif line.startswith('@@'):
147+
if in_change and current_file and old_chunk:
148+
s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk)
149+
if s:
150+
suggestions.append(s)
151+
in_change = False
152+
old_chunk = []
153+
new_chunk = []
154+
match = re.match(r'@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@', line)
155+
if match:
156+
old_line_num = int(match.group(1))
157+
new_line_num = int(match.group(2))
158+
elif line.startswith('-') and not line.startswith('---'):
159+
if not in_change:
160+
in_change = True
161+
change_old_start = old_line_num
162+
old_chunk = []
163+
new_chunk = []
164+
old_chunk.append(line[1:])
165+
old_line_num += 1
166+
elif line.startswith('+') and not line.startswith('+++'):
167+
if not in_change:
168+
in_change = True
169+
change_old_start = old_line_num
170+
old_chunk = []
171+
new_chunk = []
172+
new_chunk.append(line[1:])
173+
new_line_num += 1
174+
else:
175+
# Context line or blank
176+
if in_change and current_file and old_chunk:
177+
s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk)
178+
if s:
179+
suggestions.append(s)
180+
in_change = False
181+
old_chunk = []
182+
new_chunk = []
183+
if line.startswith(' '):
184+
old_line_num += 1
185+
new_line_num += 1
186+
187+
# Flush final change
188+
if in_change and current_file and old_chunk:
189+
s = make_suggestion(current_file, change_old_start, old_chunk, new_chunk)
190+
if s:
191+
suggestions.append(s)
192+
193+
return suggestions
194+
195+
def make_suggestion(path, old_start, old_chunk, new_chunk):
196+
if not old_chunk:
197+
return None # Pure insertions cannot be placed as inline suggestions
198+
end_line = old_start + len(old_chunk) - 1
199+
suggestion_body = '```suggestion\n' + '\n'.join(new_chunk) + '\n```'
200+
comment = {
201+
'path': path,
202+
'line': end_line,
203+
'side': 'RIGHT',
204+
'body': suggestion_body,
205+
}
206+
if len(old_chunk) > 1:
207+
comment['start_line'] = old_start
208+
comment['start_side'] = 'RIGHT'
209+
return comment
210+
211+
# Get diff of Claude's local edits vs HEAD
212+
result = subprocess.run(['git', 'diff', 'HEAD'], capture_output=True, text=True)
213+
diff_text = result.stdout
214+
215+
if not diff_text.strip():
216+
print("No changes detected. Skipping inline suggestions.")
217+
sys.exit(0)
218+
219+
suggestions = parse_diff_to_suggestions(diff_text)
220+
221+
if not suggestions:
222+
print("No inline suggestions to post (changes may be pure insertions or deletions).")
223+
sys.exit(0)
224+
225+
print(f"Posting {len(suggestions)} inline suggestion(s)...")
226+
227+
pr_number = os.environ['PR_NUMBER']
228+
head_sha = os.environ['HEAD_SHA']
229+
repo = os.environ['REPO']
230+
231+
review_payload = {
232+
'commit_id': head_sha,
233+
'body': '',
234+
'event': 'COMMENT',
235+
'comments': suggestions,
236+
}
237+
238+
result = subprocess.run(
239+
['gh', 'api', f'repos/{repo}/pulls/{pr_number}/reviews',
240+
'-X', 'POST', '--input', '-'],
241+
input=json.dumps(review_payload),
242+
capture_output=True,
243+
text=True,
244+
)
245+
246+
if result.returncode != 0:
247+
print(f"Error posting inline suggestions: {result.stderr}", file=sys.stderr)
248+
sys.exit(1)
249+
250+
print("Successfully posted inline suggestions.")
251+
PYTHON_EOF

0 commit comments

Comments
 (0)