@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clipboard-health/ai-rules",
3
3
  "description": "Pre-built AI agent rules for consistent coding standards.",
4
- "version": "2.8.4",
4
+ "version": "2.8.6",
5
5
  "bugs": "https://github.com/ClipboardHealth/core-utils/issues",
6
6
  "keywords": [
7
7
  "ai",
@@ -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
- !`node "${CLAUDE_PLUGIN_ROOT:-.agents}/skills/unresolved-pr-comments/scripts/unresolvedPrComments.ts" 2>/dev/null`
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 `node "${CLAUDE_PLUGIN_ROOT:-.agents}/skills/unresolved-pr-comments/scripts/unresolvedPrComments.ts"` and parse the JSON output
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: `node "${CLAUDE_PLUGIN_ROOT:-.agents}/skills/unresolved-pr-comments/scripts/unresolvedPrComments.ts"`
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
- node scripts/unresolvedPrComments.ts [pr-number]
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/</&lt;/g;
107
+ $text =~ s/>/&gt;/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("<", "&lt;").replaceAll(">", "&gt;").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();