@clipboard-health/ai-rules 2.8.4 → 2.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/iterate-pr/SKILL.md +3 -3
- package/skills/unresolved-pr-comments/SKILL.md +1 -1
- package/skills/unresolved-pr-comments/scripts/parseNitpicks.sh +125 -0
- package/skills/unresolved-pr-comments/scripts/unresolvedPrComments.sh +246 -0
- package/skills/unresolved-pr-comments/scripts/parseNitpicks.ts +0 -145
- package/skills/unresolved-pr-comments/scripts/unresolvedPrComments.ts +0 -190
package/package.json
CHANGED
|
@@ -28,7 +28,7 @@ Get the PR for the current branch:
|
|
|
28
28
|
|
|
29
29
|
**If PR exists:** Get unresolved comments data:
|
|
30
30
|
|
|
31
|
-
!`
|
|
31
|
+
!`bash "${CLAUDE_PLUGIN_ROOT:-.agents}/skills/unresolved-pr-comments/scripts/unresolvedPrComments.sh" 2>/dev/null`
|
|
32
32
|
|
|
33
33
|
Parse the JSON output and evaluate exit conditions.
|
|
34
34
|
|
|
@@ -75,7 +75,7 @@ Spawn a Task subagent with `subagent_type: "general-purpose"` using this prompt:
|
|
|
75
75
|
> 4. **Wait for CI**: !`rc=0; if command -v gtimeout >/dev/null 2>&1; then gtimeout 600 gh pr checks --watch || rc=$?; elif command -v timeout >/dev/null 2>&1; then timeout 600 gh pr checks --watch || rc=$?; else gh pr checks --watch || rc=$?; fi; case $rc in 0|1|8|124) ;; *) exit $rc;; esac` (10 minute timeout; exit codes: 0=pass, 1=fail, 8=pending, 124=timeout are expected and handled in next step; other codes like 4=auth error are re-raised; uses gtimeout on macOS, timeout on Linux, no timeout as fallback)
|
|
76
76
|
> 5. **Check CI Status**: Run `gh pr checks --json name,state,bucket` and parse the output
|
|
77
77
|
> - If any check has `bucket: "fail"`, invoke `core:fix-ci` via the Skill tool. Since you are running autonomously, do NOT wait for user approval — apply the fixes directly. Report what was fixed and exit.
|
|
78
|
-
> 6. **Check Comments**: Run `
|
|
78
|
+
> 6. **Check Comments**: Run `bash "${CLAUDE_PLUGIN_ROOT:-.agents}/skills/unresolved-pr-comments/scripts/unresolvedPrComments.sh"` and parse the JSON output
|
|
79
79
|
> - If unresolved comments or nitpicks exist:
|
|
80
80
|
> 1. Group comments by file path and read each file once (not per-comment)
|
|
81
81
|
> 2. If a file no longer exists, note the comment may be outdated and skip it
|
|
@@ -92,7 +92,7 @@ After the subagent completes:
|
|
|
92
92
|
|
|
93
93
|
1. Increment iteration counter
|
|
94
94
|
2. If no commits were made this iteration:
|
|
95
|
-
- Get unresolved comments by running: `
|
|
95
|
+
- Get unresolved comments by running: `bash "${CLAUDE_PLUGIN_ROOT:-.agents}/skills/unresolved-pr-comments/scripts/unresolvedPrComments.sh"`
|
|
96
96
|
- If unresolved comments remain, exit with: "Comments addressed, awaiting reviewer resolution. Run `/iterate-pr` after reviewer responds."
|
|
97
97
|
3. Report: "Iteration [N]/[max] complete. Checking state..."
|
|
98
98
|
4. Return to Step 2
|
|
@@ -13,7 +13,7 @@ Fetch and analyze unresolved review comments from a GitHub pull request.
|
|
|
13
13
|
Run the script to fetch PR comment data:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
16
|
+
bash scripts/unresolvedPrComments.sh [pr-number]
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
If no PR number is provided, it uses the PR associated with the current branch.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# parseNitpicks.sh — Parse CodeRabbit nitpick comments from PR review bodies.
|
|
3
|
+
# Sourced by unresolvedPrComments.sh. Requires: jq, perl.
|
|
4
|
+
|
|
5
|
+
# Extract nitpick comments from reviews JSON (passed via stdin).
|
|
6
|
+
# Outputs a JSON array of nitpick comment objects.
|
|
7
|
+
extract_nitpick_comments() {
|
|
8
|
+
local reviews_json="$1"
|
|
9
|
+
|
|
10
|
+
printf '%s' "$reviews_json" | perl -e '
|
|
11
|
+
use strict;
|
|
12
|
+
use warnings;
|
|
13
|
+
use JSON::PP;
|
|
14
|
+
|
|
15
|
+
local $/;
|
|
16
|
+
my $reviews_json = <STDIN>;
|
|
17
|
+
my $reviews = decode_json($reviews_json);
|
|
18
|
+
|
|
19
|
+
# Find latest coderabbitai review with nitpick section
|
|
20
|
+
my $latest_review;
|
|
21
|
+
my $latest_time = "";
|
|
22
|
+
for my $review (@$reviews) {
|
|
23
|
+
my $author = $review->{author}{login} // "";
|
|
24
|
+
my $body = $review->{body} // "";
|
|
25
|
+
next unless $author eq "coderabbitai" && $body =~ /Nitpick comments/;
|
|
26
|
+
my $created = $review->{createdAt} // "";
|
|
27
|
+
if ($created gt $latest_time) {
|
|
28
|
+
$latest_time = $created;
|
|
29
|
+
$latest_review = $review;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
unless ($latest_review) {
|
|
34
|
+
print "[]";
|
|
35
|
+
exit 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
my $body = $latest_review->{body};
|
|
39
|
+
my $author = $latest_review->{author}{login} // "deleted-user";
|
|
40
|
+
my $created_at = $latest_review->{createdAt} // "";
|
|
41
|
+
|
|
42
|
+
# Extract nitpick section content (handle nested blockquotes)
|
|
43
|
+
my $nitpick_content = extract_nitpick_section($body);
|
|
44
|
+
unless (defined $nitpick_content) {
|
|
45
|
+
print "[]";
|
|
46
|
+
exit 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Extract file sections: <details><summary>filename (count)</summary><blockquote>...</blockquote></details>
|
|
50
|
+
my @comments;
|
|
51
|
+
while ($nitpick_content =~ /<details>\s*<summary>([^<]+?)\s+\(\d+\)<\/summary>\s*<blockquote>([\s\S]*?)<\/blockquote>\s*<\/details>/g) {
|
|
52
|
+
my $file_name = trim($1);
|
|
53
|
+
my $file_content = $2;
|
|
54
|
+
|
|
55
|
+
# Extract individual comments: `line-range`: **title** body
|
|
56
|
+
while ($file_content =~ /`(\d+(?:-\d+)?)`:\s*\*\*([^*]+)\*\*\s*([\s\S]*?)(?=---|\n`\d|<\/blockquote>|$)/g) {
|
|
57
|
+
my $line_range = $1;
|
|
58
|
+
my $title = trim($2);
|
|
59
|
+
my $clean_body = clean_comment_body(trim($3));
|
|
60
|
+
push @comments, {
|
|
61
|
+
author => $author,
|
|
62
|
+
body => "$title\n\n$clean_body",
|
|
63
|
+
createdAt => $created_at,
|
|
64
|
+
file => $file_name,
|
|
65
|
+
line => $line_range,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
print encode_json(\@comments);
|
|
71
|
+
|
|
72
|
+
sub extract_nitpick_section {
|
|
73
|
+
my ($text) = @_;
|
|
74
|
+
# Match the nitpick section header
|
|
75
|
+
if ($text =~ /<summary>\x{1f9f9} Nitpick comments \(\d+\)<\/summary>\s*<blockquote>/i) {
|
|
76
|
+
my $content_start = $+[0];
|
|
77
|
+
my $after = substr($text, $content_start);
|
|
78
|
+
|
|
79
|
+
# Find matching closing blockquote by tracking depth
|
|
80
|
+
my $depth = 1;
|
|
81
|
+
my @tags;
|
|
82
|
+
while ($after =~ /(<blockquote>|<\/blockquote>)/gi) {
|
|
83
|
+
my $tag = $1;
|
|
84
|
+
my $pos = $-[0];
|
|
85
|
+
my $is_open = ($tag =~ /^<blockquote>/i) ? 1 : 0;
|
|
86
|
+
push @tags, [$pos, $is_open];
|
|
87
|
+
}
|
|
88
|
+
for my $tag (@tags) {
|
|
89
|
+
$depth += $tag->[1] ? 1 : -1;
|
|
90
|
+
if ($depth == 0) {
|
|
91
|
+
return substr($after, 0, $tag->[0]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undef;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
sub clean_comment_body {
|
|
99
|
+
my ($text) = @_;
|
|
100
|
+
# Iteratively remove innermost <details> elements
|
|
101
|
+
my $prev = "";
|
|
102
|
+
while ($text ne $prev) {
|
|
103
|
+
$prev = $text;
|
|
104
|
+
$text =~ s/<details>(?:(?!<details>)[\s\S])*?<\/details>//g;
|
|
105
|
+
}
|
|
106
|
+
$text =~ s/</</g;
|
|
107
|
+
$text =~ s/>/>/g;
|
|
108
|
+
return trim($text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
sub trim {
|
|
112
|
+
my ($s) = @_;
|
|
113
|
+
$s =~ s/^\s+//;
|
|
114
|
+
$s =~ s/\s+$//;
|
|
115
|
+
return $s;
|
|
116
|
+
}
|
|
117
|
+
'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Extract code scanning alert number from comment body.
|
|
121
|
+
# Outputs the alert number or empty string.
|
|
122
|
+
extract_code_scanning_alert_number() {
|
|
123
|
+
local body="$1"
|
|
124
|
+
printf '%s' "$body" | perl -ne 'print $1 if m{/code-scanning/(\d+)}'
|
|
125
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# unresolvedPrComments.sh — Fetch unresolved review comments from a GitHub PR.
|
|
3
|
+
# Usage: bash unresolvedPrComments.sh [pr-number]
|
|
4
|
+
# Outputs JSON with unresolved comments and CodeRabbit nitpicks.
|
|
5
|
+
# Compatible with macOS bash 3.2. Requires: gh, jq, perl.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
# shellcheck source=parseNitpicks.sh
|
|
11
|
+
source "${SCRIPT_DIR}/parseNitpicks.sh"
|
|
12
|
+
|
|
13
|
+
# Save original stdout so output_error works inside $() command substitutions
|
|
14
|
+
exec 3>&1
|
|
15
|
+
|
|
16
|
+
# --- Helpers (inlined from ghClient.ts / prClient.ts) ---
|
|
17
|
+
|
|
18
|
+
output_error() {
|
|
19
|
+
printf '%s' "$1" | jq -Rsc '{ error: . }' >&3
|
|
20
|
+
exit 1
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
validate_prerequisites() {
|
|
24
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
25
|
+
printf '{"error":"jq not found. Install from https://stedolan.github.io/jq"}\n' >&3
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
29
|
+
output_error "gh CLI not found. Install from https://cli.github.com"
|
|
30
|
+
fi
|
|
31
|
+
if ! command -v perl >/dev/null 2>&1; then
|
|
32
|
+
output_error "perl not found."
|
|
33
|
+
fi
|
|
34
|
+
if ! gh api user --jq '.login' >/dev/null 2>&1; then
|
|
35
|
+
output_error "Not authenticated with GitHub. Run: gh auth login"
|
|
36
|
+
fi
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get_pr_number() {
|
|
40
|
+
local arg="${1:-}"
|
|
41
|
+
if [ -n "$arg" ]; then
|
|
42
|
+
if ! printf '%s' "$arg" | grep -qE '^[0-9]+$'; then
|
|
43
|
+
output_error "Invalid PR number: ${arg}"
|
|
44
|
+
fi
|
|
45
|
+
printf '%s' "$arg"
|
|
46
|
+
return
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
local pr_json
|
|
50
|
+
if ! pr_json="$(gh pr view --json number 2>/dev/null)"; then
|
|
51
|
+
output_error "No PR found for current branch. Provide PR number as argument."
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
local pr_num
|
|
55
|
+
pr_num="$(printf '%s' "$pr_json" | jq -r '.number // empty')"
|
|
56
|
+
if [ -z "$pr_num" ]; then
|
|
57
|
+
output_error "No PR found for current branch. Provide PR number as argument."
|
|
58
|
+
fi
|
|
59
|
+
printf '%s' "$pr_num"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get_repo_info() {
|
|
63
|
+
local repo_json
|
|
64
|
+
if ! repo_json="$(gh repo view --json owner,name 2>/dev/null)"; then
|
|
65
|
+
output_error "Could not determine repository. Are you in a git repo with a GitHub remote?"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
REPO_OWNER="$(printf '%s' "$repo_json" | jq -r '.owner.login // empty')"
|
|
69
|
+
REPO_NAME="$(printf '%s' "$repo_json" | jq -r '.name // empty')"
|
|
70
|
+
|
|
71
|
+
if [ -z "$REPO_OWNER" ] || [ -z "$REPO_NAME" ]; then
|
|
72
|
+
output_error "Failed to parse repository info from gh CLI output."
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# --- GraphQL ---
|
|
77
|
+
|
|
78
|
+
# Pagination limits: 100 review threads, 10 comments per thread, 100 reviews.
|
|
79
|
+
# Sufficient for typical PRs; data may be truncated on exceptionally active PRs.
|
|
80
|
+
GRAPHQL_QUERY='
|
|
81
|
+
query($owner: String!, $repo: String!, $pr: Int!) {
|
|
82
|
+
repository(owner: $owner, name: $repo) {
|
|
83
|
+
pullRequest(number: $pr) {
|
|
84
|
+
title
|
|
85
|
+
url
|
|
86
|
+
reviewThreads(first: 100) {
|
|
87
|
+
nodes {
|
|
88
|
+
isResolved
|
|
89
|
+
comments(first: 10) {
|
|
90
|
+
nodes {
|
|
91
|
+
body
|
|
92
|
+
path
|
|
93
|
+
line
|
|
94
|
+
originalLine
|
|
95
|
+
author { login }
|
|
96
|
+
createdAt
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
reviews(first: 100) {
|
|
102
|
+
nodes {
|
|
103
|
+
body
|
|
104
|
+
author { login }
|
|
105
|
+
createdAt
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}'
|
|
111
|
+
|
|
112
|
+
execute_graphql_query() {
|
|
113
|
+
local owner="$1" repo="$2" pr_number="$3"
|
|
114
|
+
local result
|
|
115
|
+
if ! result="$(gh api graphql \
|
|
116
|
+
-f "query=${GRAPHQL_QUERY}" \
|
|
117
|
+
-f "owner=${owner}" \
|
|
118
|
+
-f "repo=${repo}" \
|
|
119
|
+
-F "pr=${pr_number}" 2>&1)"; then
|
|
120
|
+
output_error "GraphQL query failed: ${result}"
|
|
121
|
+
fi
|
|
122
|
+
printf '%s' "$result"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# --- Code scanning filter ---
|
|
126
|
+
|
|
127
|
+
is_code_scanning_alert_fixed() {
|
|
128
|
+
local owner="$1" repo="$2" alert_number="$3"
|
|
129
|
+
local result
|
|
130
|
+
if ! result="$(gh api "repos/${owner}/${repo}/code-scanning/alerts/${alert_number}" 2>/dev/null)"; then
|
|
131
|
+
return 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
local state
|
|
135
|
+
state="$(printf '%s' "$result" | jq -r '.most_recent_instance.state // empty')"
|
|
136
|
+
[ "$state" = "fixed" ]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# --- Main ---
|
|
140
|
+
|
|
141
|
+
main() {
|
|
142
|
+
validate_prerequisites
|
|
143
|
+
|
|
144
|
+
local pr_number
|
|
145
|
+
pr_number="$(get_pr_number "${1:-}")"
|
|
146
|
+
|
|
147
|
+
get_repo_info
|
|
148
|
+
local owner="$REPO_OWNER"
|
|
149
|
+
local repo="$REPO_NAME"
|
|
150
|
+
|
|
151
|
+
local response
|
|
152
|
+
response="$(execute_graphql_query "$owner" "$repo" "$pr_number")"
|
|
153
|
+
|
|
154
|
+
# Validate response
|
|
155
|
+
if [ "$(printf '%s' "$response" | jq -r '.data.repository // empty')" = "" ]; then
|
|
156
|
+
output_error "Repository ${owner}/${repo} not found or not accessible."
|
|
157
|
+
fi
|
|
158
|
+
if [ "$(printf '%s' "$response" | jq -r '.data.repository.pullRequest // empty')" = "" ]; then
|
|
159
|
+
output_error "PR #${pr_number} not found or not accessible."
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
local title url
|
|
163
|
+
title="$(printf '%s' "$response" | jq -r '.data.repository.pullRequest.title')"
|
|
164
|
+
url="$(printf '%s' "$response" | jq -r '.data.repository.pullRequest.url')"
|
|
165
|
+
|
|
166
|
+
# Extract unresolved comments: filter unresolved threads, flatten comments
|
|
167
|
+
local all_unresolved
|
|
168
|
+
all_unresolved="$(printf '%s' "$response" | jq '[
|
|
169
|
+
.data.repository.pullRequest.reviewThreads.nodes[]
|
|
170
|
+
| select(.isResolved == false)
|
|
171
|
+
| .comments.nodes[]
|
|
172
|
+
| {
|
|
173
|
+
author: (.author.login // "deleted-user"),
|
|
174
|
+
body: .body,
|
|
175
|
+
createdAt: .createdAt,
|
|
176
|
+
file: .path,
|
|
177
|
+
line: (.line // .originalLine)
|
|
178
|
+
}
|
|
179
|
+
]')"
|
|
180
|
+
|
|
181
|
+
# Filter out fixed code-scanning alerts from github-advanced-security
|
|
182
|
+
local unresolved_comments="[]"
|
|
183
|
+
local count
|
|
184
|
+
count="$(printf '%s' "$all_unresolved" | jq 'length')"
|
|
185
|
+
|
|
186
|
+
local i=0
|
|
187
|
+
while [ "$i" -lt "$count" ]; do
|
|
188
|
+
local comment
|
|
189
|
+
comment="$(printf '%s' "$all_unresolved" | jq ".[$i]")"
|
|
190
|
+
local comment_author
|
|
191
|
+
comment_author="$(printf '%s' "$comment" | jq -r '.author')"
|
|
192
|
+
|
|
193
|
+
local keep=true
|
|
194
|
+
if [ "$comment_author" = "github-advanced-security" ]; then
|
|
195
|
+
local comment_body alert_number
|
|
196
|
+
comment_body="$(printf '%s' "$comment" | jq -r '.body')"
|
|
197
|
+
alert_number="$(extract_code_scanning_alert_number "$comment_body")"
|
|
198
|
+
if [ -n "$alert_number" ]; then
|
|
199
|
+
if is_code_scanning_alert_fixed "$owner" "$repo" "$alert_number"; then
|
|
200
|
+
keep=false
|
|
201
|
+
fi
|
|
202
|
+
fi
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
if [ "$keep" = true ]; then
|
|
206
|
+
unresolved_comments="$(printf '%s' "$unresolved_comments" | jq --argjson c "$comment" '. + [$c]')"
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
i=$((i + 1))
|
|
210
|
+
done
|
|
211
|
+
|
|
212
|
+
# Extract nitpick comments from reviews
|
|
213
|
+
local reviews_json
|
|
214
|
+
reviews_json="$(printf '%s' "$response" | jq '[.data.repository.pullRequest.reviews.nodes[]]')"
|
|
215
|
+
local nitpick_comments
|
|
216
|
+
nitpick_comments="$(extract_nitpick_comments "$reviews_json")"
|
|
217
|
+
|
|
218
|
+
# Build final output
|
|
219
|
+
local total_unresolved total_nitpicks
|
|
220
|
+
total_unresolved="$(printf '%s' "$unresolved_comments" | jq 'length')"
|
|
221
|
+
total_nitpicks="$(printf '%s' "$nitpick_comments" | jq 'length')"
|
|
222
|
+
|
|
223
|
+
jq -n \
|
|
224
|
+
--argjson nitpickComments "$nitpick_comments" \
|
|
225
|
+
--arg owner "$owner" \
|
|
226
|
+
--argjson prNumber "$pr_number" \
|
|
227
|
+
--arg repo "$repo" \
|
|
228
|
+
--arg title "$title" \
|
|
229
|
+
--argjson totalNitpicks "$total_nitpicks" \
|
|
230
|
+
--argjson totalUnresolvedComments "$total_unresolved" \
|
|
231
|
+
--argjson unresolvedComments "$unresolved_comments" \
|
|
232
|
+
--arg url "$url" \
|
|
233
|
+
'{
|
|
234
|
+
nitpickComments: $nitpickComments,
|
|
235
|
+
owner: $owner,
|
|
236
|
+
prNumber: $prNumber,
|
|
237
|
+
repo: $repo,
|
|
238
|
+
title: $title,
|
|
239
|
+
totalNitpicks: $totalNitpicks,
|
|
240
|
+
totalUnresolvedComments: $totalUnresolvedComments,
|
|
241
|
+
unresolvedComments: $unresolvedComments,
|
|
242
|
+
url: $url
|
|
243
|
+
}'
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
main "$@"
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// Matches file sections inside the nitpick block: <details><summary>filename (count)</summary><blockquote>...</blockquote></details>
|
|
2
|
-
const FILE_SECTION_REGEX =
|
|
3
|
-
/<details>\s*<summary>([^<]+?)\s+\(\d+\)<\/summary>\s*<blockquote>([\s\S]*?)<\/blockquote>\s*<\/details>/g;
|
|
4
|
-
|
|
5
|
-
// Matches individual comments: `line-range`: **title** body
|
|
6
|
-
// Stops at: horizontal rule, next comment, end of blockquote section, or end of string
|
|
7
|
-
const COMMENT_REGEX =
|
|
8
|
-
/`(\d+(?:-\d+)?)`:\s*\*\*([^*]+)\*\*\s*([\s\S]*?)(?=---|\n`\d|<\/blockquote>|$)/g;
|
|
9
|
-
|
|
10
|
-
export const NITPICK_SECTION_MARKER = "Nitpick comments";
|
|
11
|
-
|
|
12
|
-
export interface Review {
|
|
13
|
-
author: { login: string } | null;
|
|
14
|
-
body: string;
|
|
15
|
-
createdAt: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface NitpickComment {
|
|
19
|
-
author: string;
|
|
20
|
-
body: string;
|
|
21
|
-
createdAt: string;
|
|
22
|
-
file: string;
|
|
23
|
-
line: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function cleanCommentBody(body: string): string {
|
|
27
|
-
// Remove details elements iteratively to handle nested elements
|
|
28
|
-
let result = body;
|
|
29
|
-
let previousResult = "";
|
|
30
|
-
while (result !== previousResult) {
|
|
31
|
-
previousResult = result;
|
|
32
|
-
// Match innermost details elements first (those without nested details)
|
|
33
|
-
result = result.replaceAll(/<details>(?:(?!<details>)[\s\S])*?<\/details>/g, "");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return result.replaceAll("<", "<").replaceAll(">", ">").trim();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface BlockquoteTag {
|
|
40
|
-
index: number;
|
|
41
|
-
isOpen: boolean;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function findMatchingBlockquoteEnd(content: string): number | undefined {
|
|
45
|
-
const openTag = /<blockquote>/gi;
|
|
46
|
-
const closeTag = /<\/blockquote>/gi;
|
|
47
|
-
|
|
48
|
-
const tags: BlockquoteTag[] = [];
|
|
49
|
-
|
|
50
|
-
let match: RegExpExecArray | null;
|
|
51
|
-
while ((match = openTag.exec(content)) !== null) {
|
|
52
|
-
tags.push({ index: match.index, isOpen: true });
|
|
53
|
-
}
|
|
54
|
-
while ((match = closeTag.exec(content)) !== null) {
|
|
55
|
-
tags.push({ index: match.index, isOpen: false });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
tags.sort((a, b) => a.index - b.index);
|
|
59
|
-
|
|
60
|
-
let depth = 1;
|
|
61
|
-
for (const tag of tags) {
|
|
62
|
-
depth += tag.isOpen ? 1 : -1;
|
|
63
|
-
if (depth === 0) {
|
|
64
|
-
return tag.index;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function extractNitpickSectionContent(body: string): string | undefined {
|
|
72
|
-
const startPattern = /<summary>🧹 Nitpick comments \(\d+\)<\/summary>\s*<blockquote>/i;
|
|
73
|
-
const startMatch = startPattern.exec(body);
|
|
74
|
-
if (!startMatch) {
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const contentStart = startMatch.index + startMatch[0].length;
|
|
79
|
-
const afterStart = body.slice(contentStart);
|
|
80
|
-
|
|
81
|
-
const endPosition = findMatchingBlockquoteEnd(afterStart);
|
|
82
|
-
if (endPosition === undefined) {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return afterStart.slice(0, endPosition);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function parseCommentsFromFileSection(
|
|
90
|
-
fileContent: string,
|
|
91
|
-
fileName: string,
|
|
92
|
-
review: Review,
|
|
93
|
-
): NitpickComment[] {
|
|
94
|
-
return [...fileContent.matchAll(COMMENT_REGEX)].map((match) => {
|
|
95
|
-
const lineRange = match[1];
|
|
96
|
-
const title = match[2].trim();
|
|
97
|
-
const cleanBody = cleanCommentBody(match[3].trim());
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
author: review.author?.login ?? "deleted-user",
|
|
101
|
-
body: `${title}\n\n${cleanBody}`,
|
|
102
|
-
createdAt: review.createdAt,
|
|
103
|
-
file: fileName,
|
|
104
|
-
line: lineRange,
|
|
105
|
-
};
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function extractNitpicksFromReview(review: Review): NitpickComment[] {
|
|
110
|
-
if (!review.body.includes(NITPICK_SECTION_MARKER)) {
|
|
111
|
-
return [];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const nitpickContent = extractNitpickSectionContent(review.body);
|
|
115
|
-
if (!nitpickContent) {
|
|
116
|
-
return [];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const fileSections = [...nitpickContent.matchAll(FILE_SECTION_REGEX)];
|
|
120
|
-
|
|
121
|
-
return fileSections.flatMap((fileMatch) => {
|
|
122
|
-
const fileName = fileMatch[1].trim();
|
|
123
|
-
const fileContent = fileMatch[2];
|
|
124
|
-
return parseCommentsFromFileSection(fileContent, fileName, review);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function getLatestCodeRabbitReview(reviews: Review[]): Review | undefined {
|
|
129
|
-
return [...reviews]
|
|
130
|
-
.filter(
|
|
131
|
-
(review) =>
|
|
132
|
-
review.author?.login === "coderabbitai" && review.body.includes(NITPICK_SECTION_MARKER),
|
|
133
|
-
)
|
|
134
|
-
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function extractNitpickComments(reviews: Review[]): NitpickComment[] {
|
|
138
|
-
const latestReview = getLatestCodeRabbitReview(reviews);
|
|
139
|
-
return latestReview ? extractNitpicksFromReview(latestReview) : [];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function extractCodeScanningAlertNumber(body: string): number | undefined {
|
|
143
|
-
const match = /\/code-scanning\/(\d+)/.exec(body);
|
|
144
|
-
return match ? Number.parseInt(match[1], 10) : undefined;
|
|
145
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
executeGraphQL,
|
|
4
|
-
outputError,
|
|
5
|
-
runGh,
|
|
6
|
-
validatePrerequisites,
|
|
7
|
-
} from "../../../lib/ghClient.ts";
|
|
8
|
-
import { getPrNumber, getRepoInfo } from "../../../lib/prClient.ts";
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
extractCodeScanningAlertNumber,
|
|
12
|
-
extractNitpickComments,
|
|
13
|
-
type NitpickComment,
|
|
14
|
-
type Review,
|
|
15
|
-
} from "./parseNitpicks.ts";
|
|
16
|
-
|
|
17
|
-
// Pagination limits: 100 review threads, 10 comments per thread, 100 reviews.
|
|
18
|
-
// Sufficient for typical PRs; data may be truncated on exceptionally active PRs.
|
|
19
|
-
const GRAPHQL_QUERY = `
|
|
20
|
-
query($owner: String!, $repo: String!, $pr: Int!) {
|
|
21
|
-
repository(owner: $owner, name: $repo) {
|
|
22
|
-
pullRequest(number: $pr) {
|
|
23
|
-
title
|
|
24
|
-
url
|
|
25
|
-
reviewThreads(first: 100) {
|
|
26
|
-
nodes {
|
|
27
|
-
isResolved
|
|
28
|
-
comments(first: 10) {
|
|
29
|
-
nodes {
|
|
30
|
-
body
|
|
31
|
-
path
|
|
32
|
-
line
|
|
33
|
-
originalLine
|
|
34
|
-
author { login }
|
|
35
|
-
createdAt
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
reviews(first: 100) {
|
|
41
|
-
nodes {
|
|
42
|
-
body
|
|
43
|
-
author { login }
|
|
44
|
-
createdAt
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}`;
|
|
50
|
-
|
|
51
|
-
interface Comment {
|
|
52
|
-
author: { login: string } | null;
|
|
53
|
-
body: string;
|
|
54
|
-
createdAt: string;
|
|
55
|
-
line: number | null;
|
|
56
|
-
originalLine: number | null;
|
|
57
|
-
path: string;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface ReviewThread {
|
|
61
|
-
comments: { nodes: Comment[] };
|
|
62
|
-
isResolved: boolean;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
interface GraphQLResponse {
|
|
66
|
-
data: {
|
|
67
|
-
repository: {
|
|
68
|
-
pullRequest: {
|
|
69
|
-
reviewThreads: { nodes: ReviewThread[] };
|
|
70
|
-
reviews: { nodes: Review[] };
|
|
71
|
-
title: string;
|
|
72
|
-
url: string;
|
|
73
|
-
} | null;
|
|
74
|
-
} | null;
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface UnresolvedComment {
|
|
79
|
-
author: string;
|
|
80
|
-
body: string;
|
|
81
|
-
createdAt: string;
|
|
82
|
-
file: string;
|
|
83
|
-
line: number | null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
interface CodeScanningInstance {
|
|
87
|
-
state: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
interface CodeScanningAlert {
|
|
91
|
-
most_recent_instance: CodeScanningInstance;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
interface OutputResult {
|
|
95
|
-
nitpickComments: NitpickComment[];
|
|
96
|
-
owner: string;
|
|
97
|
-
prNumber: number;
|
|
98
|
-
repo: string;
|
|
99
|
-
title: string;
|
|
100
|
-
totalNitpicks: number;
|
|
101
|
-
totalUnresolvedComments: number;
|
|
102
|
-
unresolvedComments: UnresolvedComment[];
|
|
103
|
-
url: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function isCodeScanningAlertFixed(owner: string, repo: string, alertNumber: number): boolean {
|
|
107
|
-
const result = runGh(["api", `repos/${owner}/${repo}/code-scanning/alerts/${alertNumber}`]);
|
|
108
|
-
if (result.status !== 0) {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const alert = JSON.parse(result.stdout) as CodeScanningAlert;
|
|
114
|
-
return alert.most_recent_instance.state === "fixed";
|
|
115
|
-
} catch {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function executeGraphQLQuery(owner: string, repo: string, prNumber: number): GraphQLResponse {
|
|
121
|
-
return executeGraphQL<GraphQLResponse>(GRAPHQL_QUERY, { owner, repo, pr: prNumber });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function formatComment(comment: Comment): UnresolvedComment {
|
|
125
|
-
return {
|
|
126
|
-
author: comment.author?.login ?? "deleted-user",
|
|
127
|
-
body: comment.body,
|
|
128
|
-
createdAt: comment.createdAt,
|
|
129
|
-
file: comment.path,
|
|
130
|
-
line: comment.line ?? comment.originalLine,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function isUnresolvedSecurityComment(
|
|
135
|
-
comment: UnresolvedComment,
|
|
136
|
-
owner: string,
|
|
137
|
-
repo: string,
|
|
138
|
-
): boolean {
|
|
139
|
-
if (comment.author !== "github-advanced-security") {
|
|
140
|
-
return true;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const alertNumber = extractCodeScanningAlertNumber(comment.body);
|
|
144
|
-
if (!alertNumber) {
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return !isCodeScanningAlertFixed(owner, repo, alertNumber);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function main(): void {
|
|
152
|
-
validatePrerequisites();
|
|
153
|
-
|
|
154
|
-
const prNumber = getPrNumber(process.argv[2]);
|
|
155
|
-
const { name: repo, owner } = getRepoInfo();
|
|
156
|
-
const response = executeGraphQLQuery(owner, repo, prNumber);
|
|
157
|
-
|
|
158
|
-
const repository = response.data.repository;
|
|
159
|
-
if (!repository) {
|
|
160
|
-
outputError(`Repository ${owner}/${repo} not found or not accessible.`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const pr = repository.pullRequest;
|
|
164
|
-
if (!pr) {
|
|
165
|
-
outputError(`PR #${prNumber} not found or not accessible.`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const unresolvedComments = pr.reviewThreads.nodes
|
|
169
|
-
.filter((thread) => !thread.isResolved)
|
|
170
|
-
.flatMap((thread) => thread.comments.nodes.map(formatComment))
|
|
171
|
-
.filter((comment) => isUnresolvedSecurityComment(comment, owner, repo));
|
|
172
|
-
|
|
173
|
-
const nitpickComments = extractNitpickComments(pr.reviews.nodes);
|
|
174
|
-
|
|
175
|
-
const output: OutputResult = {
|
|
176
|
-
nitpickComments,
|
|
177
|
-
owner,
|
|
178
|
-
prNumber,
|
|
179
|
-
repo,
|
|
180
|
-
title: pr.title,
|
|
181
|
-
totalNitpicks: nitpickComments.length,
|
|
182
|
-
totalUnresolvedComments: unresolvedComments.length,
|
|
183
|
-
unresolvedComments,
|
|
184
|
-
url: pr.url,
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
console.log(JSON.stringify(output, undefined, 2));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
main();
|