ira-review 3.1.6 → 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/dist/{bitbucket-TXGRB3VV.js → bitbucket-H2QDXN2J.js} +2 -2
- package/dist/chunk-C43CWCJF.js +343 -0
- package/dist/{chunk-ZKADAXVW.js → chunk-KMETPSAC.js} +52 -6
- package/dist/{chunk-SC7RXB4Y.js → chunk-W6VFEXAT.js} +52 -1
- package/dist/cli.js +75 -213
- package/dist/{github-65TBQVFD.js → github-FBFCO7ML.js} +2 -2
- package/dist/index.cjs +384 -223
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +384 -223
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-AFLVYFZ2.js +0 -135
|
@@ -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-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
|
|
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(
|
|
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-
|
|
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;
|