ira-review 3.1.5 → 3.1.7

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/README.github.md CHANGED
@@ -137,10 +137,6 @@ This is the feature that catches "does this actually match the ticket?" before a
137
137
 
138
138
  When a JIRA ticket has **no acceptance criteria at all**, IRA generates suggested ACs from the diff and (by default) posts them as a comment on the JIRA ticket for the Product Owner to review and refine. If your CI environment cannot or should not write back to JIRA — for example, the JIRA service account is read-only, or org policy forbids automated JIRA writes — pass `--no-post-acs-to-jira` (or set `IRA_POST_ACS_TO_JIRA=false`). The suggestions still render in the PR summary under **📝 Suggested Acceptance Criteria**, so reviewers see them either way; only the JIRA write is suppressed.
139
139
 
140
- ### "All Clear" PR Summary
141
-
142
- When IRA finishes a review with **zero issues** found and **JIRA acceptance criteria are 100% covered** (or the ticket needs no ACs), the PR summary leads with a celebratory ✅ banner: a confident, specific signal that automated review passed, alongside an explicit reminder that **human reviewer approval is still required before merge**. The banner is suppressed when an AC gap exists so the summary never reads "safe to approve" while a requirements gap is still visible — IRA augments your code review process, it doesn't replace it.
143
-
144
140
  ### Inline AI Comments
145
141
 
146
142
  Each issue is posted as an inline comment on the exact line in the PR, containing:
package/README.md CHANGED
@@ -42,7 +42,6 @@ Each issue is posted as an inline comment on the exact PR line with explanation,
42
42
  - Two-pass critical review (`--ai-model-critical`) — bulk pass uses your everyday model; only `CRITICAL`/`BLOCKER` findings are re-run against a stronger model, keeping premium-request cost low while preserving deep analysis on what matters
43
43
  - JIRA acceptance criteria validation with per-criterion pass/fail and edge case detection
44
44
  - JIRA AC auto-detection — finds AC from custom field or description automatically
45
- - "All Clear" PR summary block — celebratory ✅ banner when zero issues are found and JIRA AC coverage is 100%, with a clear "human reviewer approval is still required before merge" reminder. Suppressed automatically if any AC gap exists, so the summary never claims "safe to approve" while requirements are unmet.
46
45
  - Custom team review rules via `.ira-rules.json` (see below)
47
46
  - Test case generation from JIRA tickets (Jest, Vitest, Playwright, etc.)
48
47
  - Comment deduplication across re-runs
package/README.npm.md CHANGED
@@ -42,7 +42,6 @@ Each issue is posted as an inline comment on the exact PR line with explanation,
42
42
  - Two-pass critical review (`--ai-model-critical`) — bulk pass uses your everyday model; only `CRITICAL`/`BLOCKER` findings are re-run against a stronger model, keeping premium-request cost low while preserving deep analysis on what matters
43
43
  - JIRA acceptance criteria validation with per-criterion pass/fail and edge case detection
44
44
  - JIRA AC auto-detection — finds AC from custom field or description automatically
45
- - "All Clear" PR summary block — celebratory ✅ banner when zero issues are found and JIRA AC coverage is 100%, with a clear "human reviewer approval is still required before merge" reminder. Suppressed automatically if any AC gap exists, so the summary never claims "safe to approve" while requirements are unmet.
46
45
  - Custom team review rules via `.ira-rules.json` (see below)
47
46
  - Test case generation from JIRA tickets (Jest, Vitest, Playwright, etc.)
48
47
  - Comment deduplication across re-runs
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  BitbucketClient
4
- } from "./chunk-ZKADAXVW.js";
5
- import "./chunk-AFLVYFZ2.js";
4
+ } from "./chunk-KMETPSAC.js";
5
+ import "./chunk-C43CWCJF.js";
6
6
  export {
7
7
  BitbucketClient
8
8
  };
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/retry.ts
4
+ var DEFAULT_OPTIONS = {
5
+ maxAttempts: 3,
6
+ baseDelayMs: 1e3,
7
+ maxDelayMs: 1e4
8
+ };
9
+ var RetryableError = class extends Error {
10
+ constructor(message, statusCode) {
11
+ super(message);
12
+ this.statusCode = statusCode;
13
+ this.name = "RetryableError";
14
+ }
15
+ statusCode;
16
+ };
17
+ var TimeoutError = class extends RetryableError {
18
+ constructor(message = "Request timed out") {
19
+ super(message, 408);
20
+ this.name = "TimeoutError";
21
+ }
22
+ };
23
+ function isRetryable(error) {
24
+ if (error instanceof TimeoutError) return true;
25
+ if (error instanceof Error && error.name === "AbortError") return false;
26
+ if (error instanceof TypeError) return true;
27
+ const status = getStatusCode(error);
28
+ if (status !== void 0) {
29
+ return status === 429 || status >= 500;
30
+ }
31
+ return false;
32
+ }
33
+ function getStatusCode(error) {
34
+ if (error instanceof Error && "statusCode" in error && typeof error.statusCode === "number") {
35
+ return error.statusCode;
36
+ }
37
+ return void 0;
38
+ }
39
+ async function withRetry(fn, options = {}) {
40
+ const { maxAttempts, baseDelayMs, maxDelayMs } = {
41
+ ...DEFAULT_OPTIONS,
42
+ ...options
43
+ };
44
+ let lastError;
45
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
46
+ try {
47
+ return await fn();
48
+ } catch (error) {
49
+ lastError = error;
50
+ if (attempt === maxAttempts || !isRetryable(error)) break;
51
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
52
+ const jitter = delay * (0.5 + Math.random() * 0.5);
53
+ await sleep(jitter);
54
+ }
55
+ }
56
+ throw lastError;
57
+ }
58
+ async function fetchWithTimeout(url, init = {}, timeoutMs = 3e4) {
59
+ const controller = new AbortController();
60
+ let timedOut = false;
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ controller.abort();
64
+ }, timeoutMs);
65
+ const callerSignal = init.signal;
66
+ if (callerSignal) {
67
+ if (callerSignal.aborted) {
68
+ clearTimeout(timer);
69
+ controller.abort(callerSignal.reason);
70
+ } else {
71
+ const onCallerAbort = () => controller.abort(callerSignal.reason);
72
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
73
+ controller.signal.addEventListener(
74
+ "abort",
75
+ () => callerSignal.removeEventListener("abort", onCallerAbort),
76
+ { once: true }
77
+ );
78
+ }
79
+ }
80
+ try {
81
+ return await fetch(url, { ...init, signal: controller.signal });
82
+ } catch (error) {
83
+ if (error instanceof Error && error.name === "AbortError" && timedOut) {
84
+ throw new TimeoutError(`Request timed out after ${timeoutMs}ms`);
85
+ }
86
+ throw error;
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+ function parseApiError(status, body, provider) {
92
+ const statusMessages = {
93
+ 400: "Bad request",
94
+ 401: "Authentication failed \u2014 check your token or credentials",
95
+ 403: "Access denied \u2014 you may not have permission for this resource",
96
+ 404: "Not found \u2014 check the URL, project key, or PR number",
97
+ 408: "Request timed out \u2014 try again in a moment",
98
+ 409: "Conflict \u2014 the resource may have been modified",
99
+ 422: "Invalid request \u2014 the server couldn't process it",
100
+ 429: "Rate limited \u2014 too many requests, try again shortly",
101
+ 500: "Server error \u2014 the service is having issues",
102
+ 502: "Bad gateway \u2014 the service is temporarily unavailable",
103
+ 503: "Service unavailable \u2014 try again in a moment",
104
+ 504: "Gateway timeout \u2014 the service took too long to respond"
105
+ };
106
+ const friendlyStatus = statusMessages[status] ?? `HTTP ${status}`;
107
+ const trimmed = body.trim();
108
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
109
+ try {
110
+ const json = JSON.parse(trimmed);
111
+ const msg = json.message ?? json.error?.message ?? json.error ?? json.errors?.[0]?.message ?? json.detail;
112
+ if (typeof msg === "string" && msg.length > 0 && msg.length < 200) {
113
+ return `${provider} (${status}): ${msg}`;
114
+ }
115
+ } catch {
116
+ }
117
+ }
118
+ if (trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.includes("<body")) {
119
+ return `${provider} (${status}): ${friendlyStatus}`;
120
+ }
121
+ if (trimmed.length > 0 && trimmed.length < 150) {
122
+ return `${provider} (${status}): ${trimmed}`;
123
+ }
124
+ return `${provider} (${status}): ${friendlyStatus}`;
125
+ }
126
+ function sleep(ms) {
127
+ return new Promise((resolve) => setTimeout(resolve, ms));
128
+ }
129
+
130
+ // src/core/summaryBuilder.ts
131
+ var IRA_SUMMARY_TAG = "<!-- ira:summary -->";
132
+ function buildSummary(result, meta = {}) {
133
+ const lines = [];
134
+ const dot = computeStatusDot(result);
135
+ const riskLevel = result.risk?.level ?? "LOW";
136
+ const riskScore = result.risk?.score ?? 0;
137
+ const riskMax = result.risk?.maxScore ?? 100;
138
+ lines.push(IRA_SUMMARY_TAG);
139
+ lines.push(`### ${dot} IRA Review \xB7 ${riskLevel} risk (${riskScore}/${riskMax})`);
140
+ lines.push("");
141
+ const metrics = [];
142
+ if (typeof result.filesReviewed === "number") {
143
+ metrics.push(`${result.filesReviewed} file${result.filesReviewed === 1 ? "" : "s"} reviewed`);
144
+ }
145
+ metrics.push(`${result.comments.length} finding${result.comments.length === 1 ? "" : "s"}`);
146
+ const acMetric = formatAcMetric(result);
147
+ if (acMetric) metrics.push(acMetric);
148
+ lines.push(metrics.join(" \xB7 "));
149
+ lines.push("");
150
+ if (result.comments.length > 0) {
151
+ lines.push("## Findings");
152
+ lines.push("");
153
+ lines.push("| File | Line | Rule | Severity |");
154
+ lines.push("|---|---|---|---|");
155
+ for (const c of result.comments) {
156
+ lines.push(`| ${c.filePath} | ${c.line} | \`${c.rule}\` | ${c.severity} |`);
157
+ }
158
+ lines.push("");
159
+ }
160
+ if (result.requirementCompletion) {
161
+ const rc = result.requirementCompletion;
162
+ lines.push(`## Acceptance Criteria \u2014 ${rc.jiraKey} (${rc.completionPercentage}% covered, ${rc.metCriteria}/${rc.totalCriteria})`);
163
+ lines.push("");
164
+ for (const r of rc.requirements) {
165
+ const icon = r.coverage === "full" ? "\u2705" : r.coverage === "partial" ? "\u{1F7E1}" : "\u274C";
166
+ lines.push(`- ${icon} ${r.description}`);
167
+ if (r.coverage !== "full") {
168
+ lines.push(` > ${r.evidence}`);
169
+ }
170
+ }
171
+ if (rc.edgeCases.length > 0) {
172
+ lines.push("");
173
+ lines.push("### Edge cases not covered");
174
+ for (const e of rc.edgeCases) {
175
+ lines.push(`- ${e}`);
176
+ }
177
+ }
178
+ if (rc.parseWarning) {
179
+ lines.push("");
180
+ lines.push(`> \u26A0\uFE0F ${rc.parseWarning}`);
181
+ }
182
+ lines.push("");
183
+ } else if (result.acceptanceValidation) {
184
+ const av = result.acceptanceValidation;
185
+ const status = av.overallPass ? "all met" : "gaps found";
186
+ lines.push(`## Acceptance Criteria \u2014 ${av.jiraKey} (${status})`);
187
+ lines.push("");
188
+ lines.push(`_${av.summary}_`);
189
+ lines.push("");
190
+ for (const c of av.criteria) {
191
+ lines.push(`- ${c.met ? "\u2705" : "\u274C"} ${c.description}`);
192
+ }
193
+ lines.push("");
194
+ }
195
+ if (result.risk && result.reviewMode === "sonar" && result.risk.factors.length > 0) {
196
+ lines.push("## Risk Factors");
197
+ lines.push("");
198
+ lines.push("| Factor | Score | Detail |");
199
+ lines.push("|---|---|---|");
200
+ for (const f of result.risk.factors) {
201
+ lines.push(`| ${f.name} | ${f.score}/${f.maxScore} | ${f.detail} |`);
202
+ }
203
+ lines.push("");
204
+ }
205
+ if (result.complexity && result.complexity.hotspots.length > 0) {
206
+ lines.push("## Complexity Hotspots");
207
+ lines.push("");
208
+ lines.push("| File | Complexity | Cognitive |");
209
+ lines.push("|---|---|---|");
210
+ for (const h of result.complexity.hotspots.slice(0, 5)) {
211
+ lines.push(`| ${h.filePath} | ${h.complexity} | ${h.cognitiveComplexity} |`);
212
+ }
213
+ lines.push("");
214
+ }
215
+ if (result.acGeneration && result.acGeneration.criteria.length > 0) {
216
+ const ag = result.acGeneration;
217
+ lines.push(`## Suggested Acceptance Criteria \u2014 ${ag.jiraKey} (${ag.totalCriteria} generated)`);
218
+ lines.push("");
219
+ const postedNote = ag.postedToJira ? `Posted to JIRA as a comment for the Product Owner to review.` : `Not posted to JIRA (suggestions stay in this summary only).`;
220
+ lines.push(`> No acceptance criteria found on **${ag.jiraKey}**. IRA inferred the following from: ${ag.sources.join(", ")}. ${postedNote}`);
221
+ lines.push("");
222
+ for (const ac of ag.criteria) {
223
+ lines.push(`**${ac.id}**`);
224
+ lines.push(`- **Given** ${ac.given}`);
225
+ lines.push(`- **When** ${ac.when}`);
226
+ lines.push(`- **Then** ${ac.then}`);
227
+ lines.push("");
228
+ }
229
+ if (ag.reviewHints && ag.reviewHints.length > 0) {
230
+ lines.push(`### Questions for PO`);
231
+ lines.push(`> IRA could not determine the following from the code. Answering these will strengthen the ACs above.`);
232
+ lines.push("");
233
+ for (const hint of ag.reviewHints) {
234
+ lines.push(`- ${hint}`);
235
+ }
236
+ lines.push("");
237
+ }
238
+ if (ag.parseWarning) {
239
+ lines.push(`> \u26A0\uFE0F ${ag.parseWarning}`);
240
+ lines.push("");
241
+ }
242
+ }
243
+ if (result.testGeneration && result.testGeneration.testCases.length > 0) {
244
+ const tg = result.testGeneration;
245
+ const notTestableCount = tg.testCases.filter((tc) => tc.type === "not-testable").length;
246
+ const testableCount = tg.totalCases - notTestableCount;
247
+ const headerParts = [`${testableCount} test${testableCount !== 1 ? "s" : ""}`];
248
+ if (tg.edgeCases > 0) headerParts.push(`${tg.edgeCases} advanced cases`);
249
+ if (notTestableCount > 0) headerParts.push(`${notTestableCount} not-testable`);
250
+ lines.push(`## Generated Test Cases (${headerParts.join(", ")})`);
251
+ lines.push("");
252
+ const byCriterion = /* @__PURE__ */ new Map();
253
+ for (const tc of tg.testCases) {
254
+ const existing = byCriterion.get(tc.criterion) ?? [];
255
+ existing.push(tc);
256
+ byCriterion.set(tc.criterion, existing);
257
+ }
258
+ for (const [criterion, cases] of byCriterion) {
259
+ lines.push(`### ${criterion}`);
260
+ for (const tc of cases) {
261
+ const typeIcons = {
262
+ "happy-path": "\u2705",
263
+ "negative": "\u274C",
264
+ "boundary-value": "\u{1F532}",
265
+ "authorization": "\u{1F511}",
266
+ "integration": "\u{1F517}",
267
+ "state-workflow": "\u{1F504}",
268
+ "data-integrity": "\u{1F4CA}",
269
+ "error-recovery": "\u{1F6E1}\uFE0F",
270
+ "not-testable": "\u23ED\uFE0F"
271
+ };
272
+ const typeIcon = typeIcons[tc.type] ?? "\u2705";
273
+ lines.push(`- ${typeIcon} ${tc.description} *(${tc.type})*`);
274
+ }
275
+ lines.push("");
276
+ }
277
+ }
278
+ if (result.testGeneration?.parseWarning) {
279
+ lines.push(`> \u26A0\uFE0F ${result.testGeneration.parseWarning}`);
280
+ lines.push("");
281
+ }
282
+ lines.push("---");
283
+ lines.push(`_${formatFooter(meta)}_`);
284
+ lines.push("");
285
+ lines.push("_Human reviewer approval is still required before merge. IRA augments code review; it does not replace it._");
286
+ return lines.join("\n");
287
+ }
288
+ function computeStatusDot(result) {
289
+ const riskRank = riskRankFor(result.risk?.level ?? "LOW");
290
+ const acRank = acGapRank(result);
291
+ const blockerRank = result.comments.some((c) => c.severity === "BLOCKER") ? 3 : 0;
292
+ const worst = Math.max(riskRank, acRank, blockerRank);
293
+ return ["\u{1F7E2}", "\u{1F7E1}", "\u{1F7E0}", "\u{1F534}"][worst] ?? "\u{1F7E2}";
294
+ }
295
+ function riskRankFor(level) {
296
+ switch (level) {
297
+ case "CRITICAL":
298
+ return 3;
299
+ case "HIGH":
300
+ return 2;
301
+ case "MEDIUM":
302
+ return 1;
303
+ default:
304
+ return 0;
305
+ }
306
+ }
307
+ function acGapRank(result) {
308
+ if (result.requirementCompletion && result.requirementCompletion.completionPercentage < 100) return 1;
309
+ if (result.acceptanceValidation && !result.acceptanceValidation.overallPass) return 1;
310
+ return 0;
311
+ }
312
+ function formatAcMetric(result) {
313
+ if (result.requirementCompletion) {
314
+ const rc = result.requirementCompletion;
315
+ return `${rc.jiraKey}: ${rc.metCriteria}/${rc.totalCriteria} ACs met`;
316
+ }
317
+ if (result.acceptanceValidation) {
318
+ const av = result.acceptanceValidation;
319
+ return `${av.jiraKey}: ${av.overallPass ? "ACs met" : "AC gaps"}`;
320
+ }
321
+ if (result.acGeneration && result.acGeneration.criteria.length > 0) {
322
+ const ag = result.acGeneration;
323
+ return `${ag.jiraKey}: ${ag.totalCriteria} AC${ag.totalCriteria === 1 ? "" : "s"} suggested`;
324
+ }
325
+ return null;
326
+ }
327
+ function formatFooter(meta) {
328
+ const parts = [];
329
+ parts.push(`ira-review${meta.version ? ` ${meta.version}` : ""}`);
330
+ if (meta.aiProvider || meta.aiModel) {
331
+ parts.push([meta.aiProvider, meta.aiModel].filter(Boolean).join("/"));
332
+ }
333
+ return parts.join(" \xB7 ");
334
+ }
335
+
336
+ export {
337
+ RetryableError,
338
+ withRetry,
339
+ fetchWithTimeout,
340
+ parseApiError,
341
+ IRA_SUMMARY_TAG,
342
+ buildSummary
343
+ };
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ IRA_SUMMARY_TAG,
3
4
  RetryableError,
4
5
  fetchWithTimeout,
5
6
  parseApiError,
6
7
  withRetry
7
- } from "./chunk-AFLVYFZ2.js";
8
+ } from "./chunk-C43CWCJF.js";
8
9
 
9
10
  // src/scm/bitbucket.ts
10
11
  var BitbucketClient = class {
@@ -70,12 +71,28 @@ var BitbucketClient = class {
70
71
  });
71
72
  }
72
73
  async postSummary(summary, pullRequestId) {
73
- const url = `${this.baseUrl}/repositories/${this.workspace}/${this.repoSlug}/pullrequests/${pullRequestId}/comments`;
74
- const body = {
75
- content: { raw: summary }
76
- };
74
+ const existingId = await this.findExistingSummaryCommentId(pullRequestId);
75
+ const baseUrl = `${this.baseUrl}/repositories/${this.workspace}/${this.repoSlug}/pullrequests/${pullRequestId}/comments`;
76
+ const body = { content: { raw: summary } };
77
+ if (existingId !== null) {
78
+ await withRetry(async () => {
79
+ const response = await fetchWithTimeout(`${baseUrl}/${existingId}`, {
80
+ method: "PUT",
81
+ headers: this.headers,
82
+ body: JSON.stringify(body)
83
+ });
84
+ if (!response.ok) {
85
+ const text = await response.text();
86
+ throw new RetryableError(
87
+ parseApiError(response.status, text, "Bitbucket"),
88
+ response.status
89
+ );
90
+ }
91
+ });
92
+ return;
93
+ }
77
94
  await withRetry(async () => {
78
- const response = await fetchWithTimeout(url, {
95
+ const response = await fetchWithTimeout(baseUrl, {
79
96
  method: "POST",
80
97
  headers: this.headers,
81
98
  body: JSON.stringify(body)
@@ -89,6 +106,35 @@ var BitbucketClient = class {
89
106
  }
90
107
  });
91
108
  }
109
+ /**
110
+ * Find a previously-posted IRA summary on this PR by paging through
111
+ * /pullrequests/{id}/comments and matching the hidden `IRA_SUMMARY_TAG`.
112
+ * Returns the comment id or null. Bitbucket Cloud's PUT /comments/{id}
113
+ * does NOT require a version field (unlike Bitbucket Server).
114
+ */
115
+ async findExistingSummaryCommentId(pullRequestId) {
116
+ let url = `${this.baseUrl}/repositories/${this.workspace}/${this.repoSlug}/pullrequests/${pullRequestId}/comments?pagelen=100`;
117
+ while (url) {
118
+ const data = await withRetry(async () => {
119
+ const response = await fetchWithTimeout(url, { headers: this.headers });
120
+ if (!response.ok) {
121
+ const text = await response.text();
122
+ throw new RetryableError(
123
+ parseApiError(response.status, text, "Bitbucket"),
124
+ response.status
125
+ );
126
+ }
127
+ return await response.json();
128
+ });
129
+ for (const c of data.values) {
130
+ if (c.content?.raw && c.content.raw.includes(IRA_SUMMARY_TAG)) {
131
+ return c.id;
132
+ }
133
+ }
134
+ url = data.next;
135
+ }
136
+ return null;
137
+ }
92
138
  async getIssueComments(pullRequestId) {
93
139
  const bodies = [];
94
140
  let url = `${this.baseUrl}/repositories/${this.workspace}/${this.repoSlug}/pullrequests/${pullRequestId}/comments?pagelen=100`;
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ IRA_SUMMARY_TAG,
3
4
  RetryableError,
4
5
  fetchWithTimeout,
5
6
  parseApiError,
6
7
  withRetry
7
- } from "./chunk-AFLVYFZ2.js";
8
+ } from "./chunk-C43CWCJF.js";
8
9
 
9
10
  // src/scm/github.ts
10
11
  var GitHubClient = class {
@@ -39,6 +40,25 @@ var GitHubClient = class {
39
40
  await this.postIssueComment(comment, pullRequestId);
40
41
  }
41
42
  async postSummary(summary, pullRequestId) {
43
+ const existingId = await this.findExistingSummaryCommentId(pullRequestId);
44
+ if (existingId !== null) {
45
+ const editUrl = `${this.baseUrl}/repos/${this.owner}/${this.repo}/issues/comments/${existingId}`;
46
+ await withRetry(async () => {
47
+ const response = await fetchWithTimeout(editUrl, {
48
+ method: "PATCH",
49
+ headers: this.headers,
50
+ body: JSON.stringify({ body: summary })
51
+ });
52
+ if (!response.ok) {
53
+ const text = await response.text();
54
+ throw new RetryableError(
55
+ parseApiError(response.status, text, "GitHub"),
56
+ response.status
57
+ );
58
+ }
59
+ });
60
+ return;
61
+ }
42
62
  const url = `${this.baseUrl}/repos/${this.owner}/${this.repo}/issues/${pullRequestId}/comments`;
43
63
  await withRetry(async () => {
44
64
  const response = await fetchWithTimeout(url, {
@@ -55,6 +75,37 @@ var GitHubClient = class {
55
75
  }
56
76
  });
57
77
  }
78
+ /**
79
+ * Find a previously-posted IRA summary on this PR by paging through
80
+ * /issues/{id}/comments (NOT /pulls/.../comments — that endpoint is for
81
+ * inline review comments only, summaries live as issue-comments). Matches
82
+ * the hidden `IRA_SUMMARY_TAG` and returns the comment id or null.
83
+ */
84
+ async findExistingSummaryCommentId(pullRequestId) {
85
+ let page = 1;
86
+ while (true) {
87
+ const url = `${this.baseUrl}/repos/${this.owner}/${this.repo}/issues/${pullRequestId}/comments?per_page=100&page=${page}`;
88
+ const comments = await withRetry(async () => {
89
+ const response = await fetchWithTimeout(url, { headers: this.headers });
90
+ if (!response.ok) {
91
+ const text = await response.text();
92
+ throw new RetryableError(
93
+ parseApiError(response.status, text, "GitHub"),
94
+ response.status
95
+ );
96
+ }
97
+ return await response.json();
98
+ });
99
+ for (const c of comments) {
100
+ if (typeof c.body === "string" && c.body.includes(IRA_SUMMARY_TAG)) {
101
+ return c.id;
102
+ }
103
+ }
104
+ if (comments.length < 100) break;
105
+ page++;
106
+ }
107
+ return null;
108
+ }
58
109
  async getIssueComments(pullRequestId) {
59
110
  const bodies = [];
60
111
  let page = 1;