@made-by-moonlight/athene-plugin-scm-github 0.9.1
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/LICENSE +22 -0
- package/dist/graphql-batch.d.ts +229 -0
- package/dist/graphql-batch.d.ts.map +1 -0
- package/dist/graphql-batch.js +1007 -0
- package/dist/graphql-batch.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1169 -0
- package/dist/index.js.map +1 -0
- package/dist/lru-cache.d.ts +28 -0
- package/dist/lru-cache.d.ts.map +1 -0
- package/dist/lru-cache.js +80 -0
- package/dist/lru-cache.js.map +1 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scm-github plugin — GitHub PRs, CI checks, reviews, merge readiness.
|
|
3
|
+
*
|
|
4
|
+
* Uses the `gh` CLI for all GitHub API interactions.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { CI_STATUS, execGhObserved, memoizeAsync, recordActivityEvent, } from "@made-by-moonlight/athene-core";
|
|
10
|
+
import { enrichSessionsPRBatch as enrichSessionsPRBatchImpl, checkReviewCommentsETag, } from "./graphql-batch.js";
|
|
11
|
+
import { getWebhookHeader, parseWebhookBranchRef, parseWebhookJsonObject, parseWebhookTimestamp, } from "@made-by-moonlight/athene-core/scm-webhook-utils";
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
/** Known bot logins that produce automated review comments */
|
|
14
|
+
const BOT_AUTHORS = new Set([
|
|
15
|
+
"cursor[bot]",
|
|
16
|
+
"github-actions[bot]",
|
|
17
|
+
"codecov[bot]",
|
|
18
|
+
"sonarcloud[bot]",
|
|
19
|
+
"dependabot[bot]",
|
|
20
|
+
"renovate[bot]",
|
|
21
|
+
"codeclimate[bot]",
|
|
22
|
+
"deepsource-autofix[bot]",
|
|
23
|
+
"snyk-bot",
|
|
24
|
+
"lgtm-com[bot]",
|
|
25
|
+
]);
|
|
26
|
+
const CI_FAILURE_LOG_TAIL_LINES = 120;
|
|
27
|
+
const ciSummaryFailClosedEmitted = new Set();
|
|
28
|
+
/** Test-only: reset once-per-PR activity event guards. */
|
|
29
|
+
export function _resetGitHubActivityEventDedupeForTesting() {
|
|
30
|
+
ciSummaryFailClosedEmitted.clear();
|
|
31
|
+
}
|
|
32
|
+
async function execCli(bin, args, cwd) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await execFileAsync(bin, args, {
|
|
35
|
+
...(cwd ? { cwd } : {}),
|
|
36
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
37
|
+
timeout: 30_000,
|
|
38
|
+
});
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new Error(`${bin} ${args.slice(0, 3).join(" ")} failed: ${err.message}`, {
|
|
43
|
+
cause: err,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function gh(args) {
|
|
48
|
+
return execGhObserved(args, { component: "scm-github" }, 30_000);
|
|
49
|
+
}
|
|
50
|
+
async function ghInDir(args, cwd) {
|
|
51
|
+
return execGhObserved(args, { component: "scm-github", cwd }, 30_000);
|
|
52
|
+
}
|
|
53
|
+
async function git(args, cwd) {
|
|
54
|
+
return execCli("git", args, cwd);
|
|
55
|
+
}
|
|
56
|
+
function parseProjectRepo(projectRepo) {
|
|
57
|
+
const parts = projectRepo.split("/");
|
|
58
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
59
|
+
throw new Error(`Invalid repo format "${projectRepo}", expected "owner/repo"`);
|
|
60
|
+
}
|
|
61
|
+
return [parts[0], parts[1]];
|
|
62
|
+
}
|
|
63
|
+
function prInfoFromView(data, projectRepo) {
|
|
64
|
+
const [owner, repo] = parseProjectRepo(projectRepo);
|
|
65
|
+
return {
|
|
66
|
+
number: data.number,
|
|
67
|
+
url: data.url,
|
|
68
|
+
title: data.title,
|
|
69
|
+
owner,
|
|
70
|
+
repo,
|
|
71
|
+
branch: data.headRefName,
|
|
72
|
+
baseBranch: data.baseRefName,
|
|
73
|
+
isDraft: data.isDraft,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function isUnsupportedPrChecksJsonError(err) {
|
|
77
|
+
if (!(err instanceof Error))
|
|
78
|
+
return false;
|
|
79
|
+
return /pr checks/i.test(err.message) && /unknown json field/i.test(err.message);
|
|
80
|
+
}
|
|
81
|
+
function mapRawCheckStateToStatus(rawState) {
|
|
82
|
+
const state = (rawState ?? "").toUpperCase();
|
|
83
|
+
if (state === "IN_PROGRESS")
|
|
84
|
+
return "running";
|
|
85
|
+
if (state === "PENDING" ||
|
|
86
|
+
state === "QUEUED" ||
|
|
87
|
+
state === "REQUESTED" ||
|
|
88
|
+
state === "WAITING" ||
|
|
89
|
+
state === "EXPECTED") {
|
|
90
|
+
return "pending";
|
|
91
|
+
}
|
|
92
|
+
if (state === "SUCCESS")
|
|
93
|
+
return "passed";
|
|
94
|
+
if (state === "FAILURE" ||
|
|
95
|
+
state === "TIMED_OUT" ||
|
|
96
|
+
state === "CANCELLED" ||
|
|
97
|
+
state === "ACTION_REQUIRED" ||
|
|
98
|
+
state === "ERROR") {
|
|
99
|
+
return "failed";
|
|
100
|
+
}
|
|
101
|
+
if (state === "SKIPPED" ||
|
|
102
|
+
state === "NEUTRAL" ||
|
|
103
|
+
state === "STALE" ||
|
|
104
|
+
state === "NOT_REQUIRED" ||
|
|
105
|
+
state === "NONE" ||
|
|
106
|
+
state === "") {
|
|
107
|
+
return "skipped";
|
|
108
|
+
}
|
|
109
|
+
return "skipped";
|
|
110
|
+
}
|
|
111
|
+
function isFailedCheck(check) {
|
|
112
|
+
return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE";
|
|
113
|
+
}
|
|
114
|
+
function isDecimalId(value) {
|
|
115
|
+
return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9");
|
|
116
|
+
}
|
|
117
|
+
function extractActionRunReference(check) {
|
|
118
|
+
if (!check.url)
|
|
119
|
+
return null;
|
|
120
|
+
let pathParts;
|
|
121
|
+
try {
|
|
122
|
+
pathParts = new URL(check.url).pathname.split("/").filter(Boolean);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const actionsIndex = pathParts.findIndex((part, index) => part === "actions" && pathParts[index + 1] === "runs");
|
|
128
|
+
const runId = actionsIndex >= 0 ? pathParts[actionsIndex + 2] : undefined;
|
|
129
|
+
if (!runId || !isDecimalId(runId))
|
|
130
|
+
return null;
|
|
131
|
+
const jobIndex = pathParts.findIndex((part, index) => index > actionsIndex && part === "job");
|
|
132
|
+
const jobId = jobIndex >= 0 ? pathParts[jobIndex + 1] : undefined;
|
|
133
|
+
return {
|
|
134
|
+
runId,
|
|
135
|
+
...(jobId && isDecimalId(jobId) ? { jobId } : {}),
|
|
136
|
+
runUrl: check.url,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function tailLines(text, maxLines) {
|
|
140
|
+
const lines = text.split(/\r?\n/);
|
|
141
|
+
const tail = lines.slice(-maxLines).join("\n").trimEnd();
|
|
142
|
+
return tail.length > 0 ? tail : undefined;
|
|
143
|
+
}
|
|
144
|
+
function extractFailedStep(log) {
|
|
145
|
+
let lastStep;
|
|
146
|
+
for (const line of log.split(/\r?\n/)) {
|
|
147
|
+
const parts = line.split("\t");
|
|
148
|
+
const step = parts.length >= 3 ? parts[1]?.trim() : undefined;
|
|
149
|
+
if (step)
|
|
150
|
+
lastStep = step;
|
|
151
|
+
}
|
|
152
|
+
return lastStep;
|
|
153
|
+
}
|
|
154
|
+
async function getFailedJobLog(pr, runReference) {
|
|
155
|
+
try {
|
|
156
|
+
return await gh([
|
|
157
|
+
"run",
|
|
158
|
+
"view",
|
|
159
|
+
runReference.runId,
|
|
160
|
+
"--repo",
|
|
161
|
+
repoFlag(pr),
|
|
162
|
+
"--log-failed",
|
|
163
|
+
...(runReference.jobId ? ["--job", runReference.jobId] : []),
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (!runReference.jobId)
|
|
168
|
+
throw err;
|
|
169
|
+
return gh(["api", `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function getCIChecksFromStatusRollup(pr) {
|
|
173
|
+
const raw = await gh([
|
|
174
|
+
"pr",
|
|
175
|
+
"view",
|
|
176
|
+
String(pr.number),
|
|
177
|
+
"--repo",
|
|
178
|
+
repoFlag(pr),
|
|
179
|
+
"--json",
|
|
180
|
+
"statusCheckRollup",
|
|
181
|
+
]);
|
|
182
|
+
const data = JSON.parse(raw);
|
|
183
|
+
const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
|
|
184
|
+
return rollup
|
|
185
|
+
.map((entry) => {
|
|
186
|
+
if (!entry || typeof entry !== "object")
|
|
187
|
+
return null;
|
|
188
|
+
const row = entry;
|
|
189
|
+
const name = (typeof row["name"] === "string" && row["name"]) ||
|
|
190
|
+
(typeof row["context"] === "string" && row["context"]);
|
|
191
|
+
if (!name)
|
|
192
|
+
return null;
|
|
193
|
+
const rawState = typeof row["conclusion"] === "string"
|
|
194
|
+
? row["conclusion"]
|
|
195
|
+
: typeof row["state"] === "string"
|
|
196
|
+
? row["state"]
|
|
197
|
+
: typeof row["status"] === "string"
|
|
198
|
+
? row["status"]
|
|
199
|
+
: undefined;
|
|
200
|
+
const url = (typeof row["link"] === "string" && row["link"]) ||
|
|
201
|
+
(typeof row["detailsUrl"] === "string" && row["detailsUrl"]) ||
|
|
202
|
+
(typeof row["targetUrl"] === "string" && row["targetUrl"]) ||
|
|
203
|
+
undefined;
|
|
204
|
+
const startedAtRaw = typeof row["startedAt"] === "string"
|
|
205
|
+
? row["startedAt"]
|
|
206
|
+
: typeof row["createdAt"] === "string"
|
|
207
|
+
? row["createdAt"]
|
|
208
|
+
: undefined;
|
|
209
|
+
const completedAtRaw = typeof row["completedAt"] === "string" ? row["completedAt"] : undefined;
|
|
210
|
+
const check = {
|
|
211
|
+
name,
|
|
212
|
+
status: mapRawCheckStateToStatus(rawState),
|
|
213
|
+
conclusion: typeof rawState === "string" ? rawState.toUpperCase() : undefined,
|
|
214
|
+
startedAt: startedAtRaw ? new Date(startedAtRaw) : undefined,
|
|
215
|
+
completedAt: completedAtRaw ? new Date(completedAtRaw) : undefined,
|
|
216
|
+
};
|
|
217
|
+
if (url) {
|
|
218
|
+
check.url = url;
|
|
219
|
+
}
|
|
220
|
+
return check;
|
|
221
|
+
})
|
|
222
|
+
.filter((check) => check !== null);
|
|
223
|
+
}
|
|
224
|
+
function getGitHubWebhookConfig(project) {
|
|
225
|
+
const webhook = project.scm?.webhook;
|
|
226
|
+
return {
|
|
227
|
+
enabled: webhook?.enabled !== false,
|
|
228
|
+
path: webhook?.path ?? "/api/webhooks/github",
|
|
229
|
+
secretEnvVar: webhook?.secretEnvVar,
|
|
230
|
+
signatureHeader: webhook?.signatureHeader ?? "x-hub-signature-256",
|
|
231
|
+
eventHeader: webhook?.eventHeader ?? "x-github-event",
|
|
232
|
+
deliveryHeader: webhook?.deliveryHeader ?? "x-github-delivery",
|
|
233
|
+
maxBodyBytes: webhook?.maxBodyBytes,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function verifyGitHubSignature(body, secret, signatureHeader) {
|
|
237
|
+
if (!signatureHeader.startsWith("sha256="))
|
|
238
|
+
return false;
|
|
239
|
+
const expected = createHmac("sha256", secret).update(body).digest("hex");
|
|
240
|
+
const provided = signatureHeader.slice("sha256=".length);
|
|
241
|
+
const expectedBuffer = Buffer.from(expected, "hex");
|
|
242
|
+
const providedBuffer = Buffer.from(provided, "hex");
|
|
243
|
+
if (expectedBuffer.length !== providedBuffer.length)
|
|
244
|
+
return false;
|
|
245
|
+
return timingSafeEqual(expectedBuffer, providedBuffer);
|
|
246
|
+
}
|
|
247
|
+
function parseGitHubRepository(payload) {
|
|
248
|
+
const repository = payload["repository"];
|
|
249
|
+
if (!repository || typeof repository !== "object")
|
|
250
|
+
return undefined;
|
|
251
|
+
const repo = repository;
|
|
252
|
+
const ownerValue = repo["owner"];
|
|
253
|
+
const ownerLogin = ownerValue && typeof ownerValue === "object"
|
|
254
|
+
? ownerValue["login"]
|
|
255
|
+
: undefined;
|
|
256
|
+
const owner = typeof ownerLogin === "string" ? ownerLogin : undefined;
|
|
257
|
+
const name = typeof repo["name"] === "string" ? repo["name"] : undefined;
|
|
258
|
+
if (!owner || !name)
|
|
259
|
+
return undefined;
|
|
260
|
+
return { owner, name };
|
|
261
|
+
}
|
|
262
|
+
function parseGitHubWebhookEvent(request, payload, config) {
|
|
263
|
+
const rawEventType = getWebhookHeader(request.headers, config.eventHeader);
|
|
264
|
+
if (!rawEventType)
|
|
265
|
+
return null;
|
|
266
|
+
const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader);
|
|
267
|
+
const repository = parseGitHubRepository(payload);
|
|
268
|
+
const action = typeof payload["action"] === "string" ? payload["action"] : rawEventType;
|
|
269
|
+
if (rawEventType === "pull_request") {
|
|
270
|
+
const pullRequest = payload["pull_request"];
|
|
271
|
+
if (!pullRequest || typeof pullRequest !== "object")
|
|
272
|
+
return null;
|
|
273
|
+
const pr = pullRequest;
|
|
274
|
+
const head = pr["head"];
|
|
275
|
+
return {
|
|
276
|
+
provider: "github",
|
|
277
|
+
kind: "pull_request",
|
|
278
|
+
action,
|
|
279
|
+
rawEventType,
|
|
280
|
+
deliveryId,
|
|
281
|
+
repository,
|
|
282
|
+
prNumber: typeof payload["number"] === "number"
|
|
283
|
+
? payload["number"]
|
|
284
|
+
: typeof pr["number"] === "number"
|
|
285
|
+
? pr["number"]
|
|
286
|
+
: undefined,
|
|
287
|
+
branch: typeof head?.["ref"] === "string" ? head["ref"] : undefined,
|
|
288
|
+
sha: typeof head?.["sha"] === "string" ? head["sha"] : undefined,
|
|
289
|
+
timestamp: parseWebhookTimestamp(pr["updated_at"]),
|
|
290
|
+
data: payload,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (rawEventType === "pull_request_review" || rawEventType === "pull_request_review_comment") {
|
|
294
|
+
const pullRequest = payload["pull_request"];
|
|
295
|
+
if (!pullRequest || typeof pullRequest !== "object")
|
|
296
|
+
return null;
|
|
297
|
+
const pr = pullRequest;
|
|
298
|
+
const head = pr["head"];
|
|
299
|
+
return {
|
|
300
|
+
provider: "github",
|
|
301
|
+
kind: rawEventType === "pull_request_review" ? "review" : "comment",
|
|
302
|
+
action,
|
|
303
|
+
rawEventType,
|
|
304
|
+
deliveryId,
|
|
305
|
+
repository,
|
|
306
|
+
prNumber: typeof payload["number"] === "number"
|
|
307
|
+
? payload["number"]
|
|
308
|
+
: typeof pr["number"] === "number"
|
|
309
|
+
? pr["number"]
|
|
310
|
+
: undefined,
|
|
311
|
+
branch: typeof head?.["ref"] === "string" ? head["ref"] : undefined,
|
|
312
|
+
sha: typeof head?.["sha"] === "string" ? head["sha"] : undefined,
|
|
313
|
+
timestamp: rawEventType === "pull_request_review"
|
|
314
|
+
? parseWebhookTimestamp(payload["review"]?.["submitted_at"])
|
|
315
|
+
: parseWebhookTimestamp(payload["comment"]?.["updated_at"] ??
|
|
316
|
+
payload["comment"]?.["created_at"]),
|
|
317
|
+
data: payload,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (rawEventType === "issue_comment") {
|
|
321
|
+
const issue = payload["issue"];
|
|
322
|
+
if (!issue || typeof issue !== "object")
|
|
323
|
+
return null;
|
|
324
|
+
const issueRecord = issue;
|
|
325
|
+
if (!("pull_request" in issueRecord))
|
|
326
|
+
return null;
|
|
327
|
+
return {
|
|
328
|
+
provider: "github",
|
|
329
|
+
kind: "comment",
|
|
330
|
+
action,
|
|
331
|
+
rawEventType,
|
|
332
|
+
deliveryId,
|
|
333
|
+
repository,
|
|
334
|
+
prNumber: typeof issueRecord["number"] === "number" ? issueRecord["number"] : undefined,
|
|
335
|
+
timestamp: parseWebhookTimestamp(payload["comment"]?.["updated_at"] ??
|
|
336
|
+
payload["comment"]?.["created_at"]),
|
|
337
|
+
data: payload,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (rawEventType === "check_run" || rawEventType === "check_suite") {
|
|
341
|
+
const check = payload[rawEventType];
|
|
342
|
+
const pullRequests = Array.isArray(check?.["pull_requests"])
|
|
343
|
+
? check?.["pull_requests"]
|
|
344
|
+
: [];
|
|
345
|
+
const firstPR = pullRequests[0];
|
|
346
|
+
return {
|
|
347
|
+
provider: "github",
|
|
348
|
+
kind: "ci",
|
|
349
|
+
action,
|
|
350
|
+
rawEventType,
|
|
351
|
+
deliveryId,
|
|
352
|
+
repository,
|
|
353
|
+
prNumber: typeof firstPR?.["number"] === "number" ? firstPR["number"] : undefined,
|
|
354
|
+
branch: typeof check?.["head_branch"] === "string"
|
|
355
|
+
? check["head_branch"]
|
|
356
|
+
: typeof check?.["check_suite"]?.["head_branch"] === "string"
|
|
357
|
+
? (check?.["check_suite"])["head_branch"]
|
|
358
|
+
: undefined,
|
|
359
|
+
sha: typeof check?.["head_sha"] === "string" ? check["head_sha"] : undefined,
|
|
360
|
+
timestamp: parseWebhookTimestamp(check?.["updated_at"]),
|
|
361
|
+
data: payload,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (rawEventType === "status") {
|
|
365
|
+
const branches = Array.isArray(payload["branches"])
|
|
366
|
+
? payload["branches"]
|
|
367
|
+
: [];
|
|
368
|
+
return {
|
|
369
|
+
provider: "github",
|
|
370
|
+
kind: "ci",
|
|
371
|
+
action: typeof payload["state"] === "string" ? payload["state"] : action,
|
|
372
|
+
rawEventType,
|
|
373
|
+
deliveryId,
|
|
374
|
+
repository,
|
|
375
|
+
branch: parseWebhookBranchRef(branches[0]?.["name"] ?? payload["ref"]),
|
|
376
|
+
sha: typeof payload["sha"] === "string" ? payload["sha"] : undefined,
|
|
377
|
+
timestamp: parseWebhookTimestamp(payload["updated_at"]),
|
|
378
|
+
data: payload,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (rawEventType === "push") {
|
|
382
|
+
const headCommit = payload["head_commit"] && typeof payload["head_commit"] === "object"
|
|
383
|
+
? payload["head_commit"]
|
|
384
|
+
: undefined;
|
|
385
|
+
return {
|
|
386
|
+
provider: "github",
|
|
387
|
+
kind: "push",
|
|
388
|
+
action,
|
|
389
|
+
rawEventType,
|
|
390
|
+
deliveryId,
|
|
391
|
+
repository,
|
|
392
|
+
branch: parseWebhookBranchRef(payload["ref"]),
|
|
393
|
+
sha: typeof payload["after"] === "string" ? payload["after"] : undefined,
|
|
394
|
+
timestamp: parseWebhookTimestamp(headCommit?.["timestamp"] ?? payload["updated_at"]),
|
|
395
|
+
data: payload,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
provider: "github",
|
|
400
|
+
kind: "unknown",
|
|
401
|
+
action,
|
|
402
|
+
rawEventType,
|
|
403
|
+
deliveryId,
|
|
404
|
+
repository,
|
|
405
|
+
timestamp: parseWebhookTimestamp(payload["updated_at"]),
|
|
406
|
+
data: payload,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function repoFlag(pr) {
|
|
410
|
+
return `${pr.owner}/${pr.repo}`;
|
|
411
|
+
}
|
|
412
|
+
function prEventKey(pr) {
|
|
413
|
+
return `${repoFlag(pr)}#${pr.number}`;
|
|
414
|
+
}
|
|
415
|
+
function parseDate(val) {
|
|
416
|
+
if (!val)
|
|
417
|
+
return new Date(0);
|
|
418
|
+
const d = new Date(val);
|
|
419
|
+
return isNaN(d.getTime()) ? new Date(0) : d;
|
|
420
|
+
}
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// SCM implementation
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// In-process PR cache. Per-method TTLs balance call reduction against
|
|
425
|
+
// staleness. Tightest TTLs (5s) on the fastest-changing decision-critical
|
|
426
|
+
// fields (state, CI, mergeability) — well under one poll cycle. Slightly
|
|
427
|
+
// looser (10s) on review-state and review-comments which tolerate up to
|
|
428
|
+
// 10-30s staleness per the agreed policy and benefit measurably from a
|
|
429
|
+
// looser window in trace replay. detectPR uses 30s because once a PR is
|
|
430
|
+
// discovered for a branch, that fact is stable for the session — and 5s was
|
|
431
|
+
// far below the per-branch poll cadence (~30s), making the cache near-useless.
|
|
432
|
+
// detectPR caches positive results only (never []) so a freshly created PR
|
|
433
|
+
// is discovered on the very next poll.
|
|
434
|
+
const PR_CACHE_TTL_MS = {
|
|
435
|
+
resolvePR: 60_000, // identity metadata (number, url, title, branch refs, isDraft)
|
|
436
|
+
getPRState: 5_000, // open / merged / closed
|
|
437
|
+
getPRSummary: 5_000, // state + title + additions/deletions
|
|
438
|
+
getReviews: 10_000, // review array (state, body, author)
|
|
439
|
+
getReviewDecision: 10_000, // approved / changes_requested / pending
|
|
440
|
+
getCIChecks: 5_000, // CI check list (name, state, link, timestamps)
|
|
441
|
+
getMergeability: 5_000, // composite merge readiness
|
|
442
|
+
getPendingComments: 10_000, // unresolved review threads (GraphQL)
|
|
443
|
+
detectPR: 30_000, // positive hits only — branch-PR mapping is stable once known
|
|
444
|
+
};
|
|
445
|
+
const PR_CACHE_MAX_ENTRIES = 1000;
|
|
446
|
+
function createGitHubSCM() {
|
|
447
|
+
// Per-instance cache so each createGitHubSCM() returns an isolated cache —
|
|
448
|
+
// tests get clean state on each create() call.
|
|
449
|
+
const prCache = new Map();
|
|
450
|
+
// ETag-controlled cache for review threads + reviews. Freshness is managed by
|
|
451
|
+
// Guard 3 (checkReviewCommentsETag) — not a TTL timer.
|
|
452
|
+
const reviewThreadsCache = new Map();
|
|
453
|
+
// Instance-level observer captured from enrichSessionsPRBatch calls.
|
|
454
|
+
// Used by getReviewThreads (which can't accept observer via the SCM interface)
|
|
455
|
+
// to log non-304 errors that would otherwise be swallowed by lifecycle's catch.
|
|
456
|
+
let instanceObserver;
|
|
457
|
+
function prCacheKey(owner, repo, prKey, method) {
|
|
458
|
+
return `${owner}/${repo}#${prKey}:${method}`;
|
|
459
|
+
}
|
|
460
|
+
function readPRCache(key) {
|
|
461
|
+
const entry = prCache.get(key);
|
|
462
|
+
if (!entry)
|
|
463
|
+
return null;
|
|
464
|
+
if (Date.now() > entry.expiresAt) {
|
|
465
|
+
prCache.delete(key);
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
return entry.value;
|
|
469
|
+
}
|
|
470
|
+
function writePRCache(key, value, ttlMs) {
|
|
471
|
+
if (prCache.size >= PR_CACHE_MAX_ENTRIES) {
|
|
472
|
+
const oldest = prCache.keys().next().value;
|
|
473
|
+
if (oldest !== undefined)
|
|
474
|
+
prCache.delete(oldest);
|
|
475
|
+
}
|
|
476
|
+
prCache.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
477
|
+
}
|
|
478
|
+
// Wipe every method's cache entry for a specific PR. Called on writes
|
|
479
|
+
// (pr edit/merge/close) to avoid serving stale state after our own mutation.
|
|
480
|
+
// Also wipes the branch-keyed detectPR entry since mergePR deletes the branch.
|
|
481
|
+
function invalidatePRCache(pr) {
|
|
482
|
+
const prefix = `${pr.owner}/${pr.repo}#${pr.number}:`;
|
|
483
|
+
for (const key of prCache.keys()) {
|
|
484
|
+
if (key.startsWith(prefix))
|
|
485
|
+
prCache.delete(key);
|
|
486
|
+
}
|
|
487
|
+
prCache.delete(prCacheKey(pr.owner, pr.repo, pr.branch, "detectPR"));
|
|
488
|
+
reviewThreadsCache.delete(`${pr.owner}/${pr.repo}#${pr.number}`);
|
|
489
|
+
}
|
|
490
|
+
async function withPRCache(owner, repo, prKey, method, fetcher) {
|
|
491
|
+
const key = prCacheKey(owner, repo, prKey, method);
|
|
492
|
+
const cached = readPRCache(key);
|
|
493
|
+
if (cached !== null)
|
|
494
|
+
return cached;
|
|
495
|
+
const value = await fetcher();
|
|
496
|
+
writePRCache(key, value, PR_CACHE_TTL_MS[method]);
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
name: "github",
|
|
501
|
+
async verifyWebhook(request, project) {
|
|
502
|
+
const config = getGitHubWebhookConfig(project);
|
|
503
|
+
if (!config.enabled) {
|
|
504
|
+
return { ok: false, reason: "Webhook is disabled for this project" };
|
|
505
|
+
}
|
|
506
|
+
if (request.method.toUpperCase() !== "POST") {
|
|
507
|
+
return { ok: false, reason: "Webhook requests must use POST" };
|
|
508
|
+
}
|
|
509
|
+
if (config.maxBodyBytes !== undefined &&
|
|
510
|
+
Buffer.byteLength(request.body, "utf8") > config.maxBodyBytes) {
|
|
511
|
+
return { ok: false, reason: "Webhook payload exceeds configured maxBodyBytes" };
|
|
512
|
+
}
|
|
513
|
+
const eventType = getWebhookHeader(request.headers, config.eventHeader);
|
|
514
|
+
if (!eventType) {
|
|
515
|
+
return { ok: false, reason: `Missing ${config.eventHeader} header` };
|
|
516
|
+
}
|
|
517
|
+
const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader);
|
|
518
|
+
const secretName = config.secretEnvVar;
|
|
519
|
+
if (!secretName) {
|
|
520
|
+
return { ok: true, deliveryId, eventType };
|
|
521
|
+
}
|
|
522
|
+
const secret = process.env[secretName];
|
|
523
|
+
if (!secret) {
|
|
524
|
+
return { ok: false, reason: `Webhook secret env var ${secretName} is not configured` };
|
|
525
|
+
}
|
|
526
|
+
const signature = getWebhookHeader(request.headers, config.signatureHeader);
|
|
527
|
+
if (!signature) {
|
|
528
|
+
return { ok: false, reason: `Missing ${config.signatureHeader} header` };
|
|
529
|
+
}
|
|
530
|
+
if (!verifyGitHubSignature(request.rawBody ?? request.body, secret, signature)) {
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
reason: "Webhook signature verification failed",
|
|
534
|
+
deliveryId,
|
|
535
|
+
eventType,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
return { ok: true, deliveryId, eventType };
|
|
539
|
+
},
|
|
540
|
+
async parseWebhook(request, project) {
|
|
541
|
+
const config = getGitHubWebhookConfig(project);
|
|
542
|
+
const payload = parseWebhookJsonObject(request.body);
|
|
543
|
+
return parseGitHubWebhookEvent(request, payload, config);
|
|
544
|
+
},
|
|
545
|
+
async detectPR(session, project) {
|
|
546
|
+
if (!session.branch || !project.repo)
|
|
547
|
+
return null;
|
|
548
|
+
parseProjectRepo(project.repo);
|
|
549
|
+
const [owner, repoName] = project.repo.split("/");
|
|
550
|
+
// Positive-only cache: never cache [] (null). A just-created PR must
|
|
551
|
+
// surface on the next poll, so we pay the gh call for misses but save
|
|
552
|
+
// every call after the PR is discovered.
|
|
553
|
+
const cacheK = prCacheKey(owner ?? "", repoName ?? "", session.branch, "detectPR");
|
|
554
|
+
const cached = readPRCache(cacheK);
|
|
555
|
+
if (cached !== null)
|
|
556
|
+
return cached;
|
|
557
|
+
try {
|
|
558
|
+
const raw = await gh([
|
|
559
|
+
"pr",
|
|
560
|
+
"list",
|
|
561
|
+
"--repo",
|
|
562
|
+
project.repo,
|
|
563
|
+
"--head",
|
|
564
|
+
session.branch,
|
|
565
|
+
"--json",
|
|
566
|
+
"number,url,title,headRefName,baseRefName,isDraft",
|
|
567
|
+
"--limit",
|
|
568
|
+
"1",
|
|
569
|
+
]);
|
|
570
|
+
const prs = JSON.parse(raw);
|
|
571
|
+
if (prs.length === 0)
|
|
572
|
+
return null;
|
|
573
|
+
const info = prInfoFromView(prs[0], project.repo);
|
|
574
|
+
writePRCache(cacheK, info, PR_CACHE_TTL_MS.detectPR);
|
|
575
|
+
return info;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
async resolvePR(reference, project) {
|
|
582
|
+
if (!project.repo) {
|
|
583
|
+
throw new Error("Cannot resolve PR: project has no repo configured");
|
|
584
|
+
}
|
|
585
|
+
const repo = project.repo;
|
|
586
|
+
const [owner, repoName] = repo.split("/");
|
|
587
|
+
// Cache by reference (number, branch, or URL — caller-provided).
|
|
588
|
+
// Identity metadata (number, url, title, branch refs, isDraft) is stable
|
|
589
|
+
// for the life of a PR; 60s TTL is safely under any user-noticeable window.
|
|
590
|
+
return withPRCache(owner ?? "", repoName ?? "", `ref=${reference}`, "resolvePR", async () => {
|
|
591
|
+
const raw = await gh([
|
|
592
|
+
"pr",
|
|
593
|
+
"view",
|
|
594
|
+
reference,
|
|
595
|
+
"--repo",
|
|
596
|
+
repo,
|
|
597
|
+
"--json",
|
|
598
|
+
"number,url,title,headRefName,baseRefName,isDraft",
|
|
599
|
+
]);
|
|
600
|
+
const data = JSON.parse(raw);
|
|
601
|
+
return prInfoFromView(data, repo);
|
|
602
|
+
});
|
|
603
|
+
},
|
|
604
|
+
async assignPRToCurrentUser(pr) {
|
|
605
|
+
await gh(["pr", "edit", String(pr.number), "--repo", repoFlag(pr), "--add-assignee", "@me"]);
|
|
606
|
+
invalidatePRCache(pr);
|
|
607
|
+
},
|
|
608
|
+
async checkoutPR(pr, workspacePath) {
|
|
609
|
+
const currentBranch = await git(["branch", "--show-current"], workspacePath);
|
|
610
|
+
if (currentBranch === pr.branch)
|
|
611
|
+
return false;
|
|
612
|
+
const dirty = await git(["status", "--porcelain"], workspacePath);
|
|
613
|
+
if (dirty) {
|
|
614
|
+
throw new Error(`Workspace has uncommitted changes; cannot switch to PR branch "${pr.branch}" safely`);
|
|
615
|
+
}
|
|
616
|
+
await ghInDir(["pr", "checkout", String(pr.number), "--repo", repoFlag(pr)], workspacePath);
|
|
617
|
+
return true;
|
|
618
|
+
},
|
|
619
|
+
async getPRState(pr) {
|
|
620
|
+
// 5s TTL — state is decision-influencing (lifecycle uses it for cleanup),
|
|
621
|
+
// but 5s is well under one poll cycle so the lifecycle worker still sees
|
|
622
|
+
// freshly observed transitions on its next pass.
|
|
623
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getPRState", async () => {
|
|
624
|
+
const raw = await gh([
|
|
625
|
+
"pr",
|
|
626
|
+
"view",
|
|
627
|
+
String(pr.number),
|
|
628
|
+
"--repo",
|
|
629
|
+
repoFlag(pr),
|
|
630
|
+
"--json",
|
|
631
|
+
"state",
|
|
632
|
+
]);
|
|
633
|
+
const data = JSON.parse(raw);
|
|
634
|
+
const s = data.state.toUpperCase();
|
|
635
|
+
if (s === "MERGED")
|
|
636
|
+
return "merged";
|
|
637
|
+
if (s === "CLOSED")
|
|
638
|
+
return "closed";
|
|
639
|
+
return "open";
|
|
640
|
+
});
|
|
641
|
+
},
|
|
642
|
+
async getPRSummary(pr) {
|
|
643
|
+
// 5s TTL — includes state, so same freshness contract as getPRState.
|
|
644
|
+
// Title and additions/deletions change rarely; they ride along.
|
|
645
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getPRSummary", async () => {
|
|
646
|
+
const raw = await gh([
|
|
647
|
+
"pr",
|
|
648
|
+
"view",
|
|
649
|
+
String(pr.number),
|
|
650
|
+
"--repo",
|
|
651
|
+
repoFlag(pr),
|
|
652
|
+
"--json",
|
|
653
|
+
"state,title,additions,deletions",
|
|
654
|
+
]);
|
|
655
|
+
const data = JSON.parse(raw);
|
|
656
|
+
const s = data.state.toUpperCase();
|
|
657
|
+
const state = s === "MERGED" ? "merged" : s === "CLOSED" ? "closed" : "open";
|
|
658
|
+
return {
|
|
659
|
+
state,
|
|
660
|
+
title: data.title ?? "",
|
|
661
|
+
additions: data.additions ?? 0,
|
|
662
|
+
deletions: data.deletions ?? 0,
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
async mergePR(pr, method = "squash") {
|
|
667
|
+
const flag = method === "rebase" ? "--rebase" : method === "merge" ? "--merge" : "--squash";
|
|
668
|
+
await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), flag, "--delete-branch"]);
|
|
669
|
+
invalidatePRCache(pr);
|
|
670
|
+
},
|
|
671
|
+
async closePR(pr) {
|
|
672
|
+
await gh(["pr", "close", String(pr.number), "--repo", repoFlag(pr)]);
|
|
673
|
+
invalidatePRCache(pr);
|
|
674
|
+
},
|
|
675
|
+
async getCIChecks(pr) {
|
|
676
|
+
// 5s TTL — CI state can flip quickly; within one poll cycle is acceptable
|
|
677
|
+
// per the agreed fast-changing-fields policy. Fallback to statusCheckRollup
|
|
678
|
+
// for older gh CLI versions happens inside the fetcher and rides on the
|
|
679
|
+
// same cache entry.
|
|
680
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getCIChecks", async () => {
|
|
681
|
+
try {
|
|
682
|
+
const raw = await gh([
|
|
683
|
+
"pr",
|
|
684
|
+
"checks",
|
|
685
|
+
String(pr.number),
|
|
686
|
+
"--repo",
|
|
687
|
+
repoFlag(pr),
|
|
688
|
+
"--json",
|
|
689
|
+
"name,state,link,startedAt,completedAt",
|
|
690
|
+
]);
|
|
691
|
+
const checks = JSON.parse(raw);
|
|
692
|
+
return checks.map((c) => {
|
|
693
|
+
const state = c.state?.toUpperCase();
|
|
694
|
+
return {
|
|
695
|
+
name: c.name,
|
|
696
|
+
status: mapRawCheckStateToStatus(state),
|
|
697
|
+
url: c.link || undefined,
|
|
698
|
+
conclusion: state || undefined,
|
|
699
|
+
startedAt: c.startedAt ? new Date(c.startedAt) : undefined,
|
|
700
|
+
completedAt: c.completedAt ? new Date(c.completedAt) : undefined,
|
|
701
|
+
};
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
if (isUnsupportedPrChecksJsonError(err)) {
|
|
706
|
+
return getCIChecksFromStatusRollup(pr);
|
|
707
|
+
}
|
|
708
|
+
throw new Error("Failed to fetch CI checks", { cause: err });
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
},
|
|
712
|
+
async getCIFailureSummary(pr, providedFailedChecks) {
|
|
713
|
+
try {
|
|
714
|
+
const failedChecks = (providedFailedChecks ?? (await this.getCIChecks(pr))).filter(isFailedCheck);
|
|
715
|
+
if (failedChecks.length === 0)
|
|
716
|
+
return null;
|
|
717
|
+
const failedJobs = [];
|
|
718
|
+
const seenRuns = new Set();
|
|
719
|
+
for (const check of failedChecks) {
|
|
720
|
+
const runReference = extractActionRunReference(check);
|
|
721
|
+
if (!runReference)
|
|
722
|
+
continue;
|
|
723
|
+
const seenKey = `${runReference.runId}:${runReference.jobId ?? ""}`;
|
|
724
|
+
if (seenRuns.has(seenKey))
|
|
725
|
+
continue;
|
|
726
|
+
seenRuns.add(seenKey);
|
|
727
|
+
const log = await getFailedJobLog(pr, runReference);
|
|
728
|
+
const failedJob = {
|
|
729
|
+
name: check.name,
|
|
730
|
+
runUrl: runReference.runUrl,
|
|
731
|
+
};
|
|
732
|
+
const failedStep = extractFailedStep(log);
|
|
733
|
+
if (failedStep)
|
|
734
|
+
failedJob.failedStep = failedStep;
|
|
735
|
+
const logTail = tailLines(log, CI_FAILURE_LOG_TAIL_LINES);
|
|
736
|
+
if (logTail)
|
|
737
|
+
failedJob.logTail = logTail;
|
|
738
|
+
failedJobs.push(failedJob);
|
|
739
|
+
}
|
|
740
|
+
return failedJobs.length > 0 ? { failedJobs } : null;
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
async getCISummary(pr) {
|
|
747
|
+
let checks;
|
|
748
|
+
try {
|
|
749
|
+
checks = await this.getCIChecks(pr);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
// Before fail-closing, check if the PR is merged/closed —
|
|
753
|
+
// GitHub may not return check data for those, and reporting
|
|
754
|
+
// "failing" for a merged PR is wrong.
|
|
755
|
+
try {
|
|
756
|
+
const state = await this.getPRState(pr);
|
|
757
|
+
if (state === "merged" || state === "closed")
|
|
758
|
+
return "none";
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// Can't determine state either; fall through to fail-closed.
|
|
762
|
+
}
|
|
763
|
+
// Fail closed for open PRs: report as failing rather than
|
|
764
|
+
// "none" (which getMergeability treats as passing). Emit so RCA
|
|
765
|
+
// can distinguish "really failing" from "we couldn't tell".
|
|
766
|
+
const eventKey = prEventKey(pr);
|
|
767
|
+
if (!ciSummaryFailClosedEmitted.has(eventKey)) {
|
|
768
|
+
ciSummaryFailClosedEmitted.add(eventKey);
|
|
769
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
770
|
+
recordActivityEvent({
|
|
771
|
+
source: "scm",
|
|
772
|
+
kind: "scm.ci_summary_failclosed",
|
|
773
|
+
level: "warn",
|
|
774
|
+
summary: `getCISummary failed-closed for PR #${pr.number}`,
|
|
775
|
+
data: {
|
|
776
|
+
plugin: "scm-github",
|
|
777
|
+
prNumber: pr.number,
|
|
778
|
+
prOwner: pr.owner,
|
|
779
|
+
prRepo: pr.repo,
|
|
780
|
+
errorMessage,
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return "failing";
|
|
785
|
+
}
|
|
786
|
+
if (checks.length === 0)
|
|
787
|
+
return "none";
|
|
788
|
+
const hasFailing = checks.some((c) => c.status === "failed");
|
|
789
|
+
if (hasFailing)
|
|
790
|
+
return "failing";
|
|
791
|
+
const hasPending = checks.some((c) => c.status === "pending" || c.status === "running");
|
|
792
|
+
if (hasPending)
|
|
793
|
+
return "pending";
|
|
794
|
+
// Only report passing if at least one check actually passed
|
|
795
|
+
// (not all skipped)
|
|
796
|
+
const hasPassing = checks.some((c) => c.status === "passed");
|
|
797
|
+
if (!hasPassing)
|
|
798
|
+
return "none";
|
|
799
|
+
return "passing";
|
|
800
|
+
},
|
|
801
|
+
async getReviews(pr) {
|
|
802
|
+
// 5s TTL — review array. Reviewers are async, so the lifecycle worker
|
|
803
|
+
// sees a new review on its next poll cycle within 5s of the cache expiring.
|
|
804
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getReviews", async () => {
|
|
805
|
+
const raw = await gh([
|
|
806
|
+
"pr",
|
|
807
|
+
"view",
|
|
808
|
+
String(pr.number),
|
|
809
|
+
"--repo",
|
|
810
|
+
repoFlag(pr),
|
|
811
|
+
"--json",
|
|
812
|
+
"reviews",
|
|
813
|
+
]);
|
|
814
|
+
const data = JSON.parse(raw);
|
|
815
|
+
return data.reviews.map((r) => {
|
|
816
|
+
let state;
|
|
817
|
+
const s = r.state?.toUpperCase();
|
|
818
|
+
if (s === "APPROVED")
|
|
819
|
+
state = "approved";
|
|
820
|
+
else if (s === "CHANGES_REQUESTED")
|
|
821
|
+
state = "changes_requested";
|
|
822
|
+
else if (s === "DISMISSED")
|
|
823
|
+
state = "dismissed";
|
|
824
|
+
else if (s === "PENDING")
|
|
825
|
+
state = "pending";
|
|
826
|
+
else
|
|
827
|
+
state = "commented";
|
|
828
|
+
return {
|
|
829
|
+
author: r.author?.login ?? "unknown",
|
|
830
|
+
state,
|
|
831
|
+
body: r.body || undefined,
|
|
832
|
+
submittedAt: parseDate(r.submittedAt),
|
|
833
|
+
};
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
},
|
|
837
|
+
async getReviewDecision(pr) {
|
|
838
|
+
// 5s TTL — review decision is decision-influencing (gates merge), kept
|
|
839
|
+
// tight so a fresh "approved" surfaces within one poll cycle.
|
|
840
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getReviewDecision", async () => {
|
|
841
|
+
const raw = await gh([
|
|
842
|
+
"pr",
|
|
843
|
+
"view",
|
|
844
|
+
String(pr.number),
|
|
845
|
+
"--repo",
|
|
846
|
+
repoFlag(pr),
|
|
847
|
+
"--json",
|
|
848
|
+
"reviewDecision",
|
|
849
|
+
]);
|
|
850
|
+
const data = JSON.parse(raw);
|
|
851
|
+
const d = (data.reviewDecision ?? "").toUpperCase();
|
|
852
|
+
if (d === "APPROVED")
|
|
853
|
+
return "approved";
|
|
854
|
+
if (d === "CHANGES_REQUESTED")
|
|
855
|
+
return "changes_requested";
|
|
856
|
+
if (d === "REVIEW_REQUIRED")
|
|
857
|
+
return "pending";
|
|
858
|
+
return "none";
|
|
859
|
+
});
|
|
860
|
+
},
|
|
861
|
+
async getPendingComments(pr) {
|
|
862
|
+
// 5s TTL — review threads are decision-influencing (gates whether AO
|
|
863
|
+
// reacts to new comments). Within one poll cycle is acceptable. Note:
|
|
864
|
+
// ETag does not work on /graphql per Experiment 2 (G2), so TTL is the
|
|
865
|
+
// only practical lever here.
|
|
866
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getPendingComments", async () => {
|
|
867
|
+
try {
|
|
868
|
+
// Use GraphQL with variables to get review threads with actual isResolved status
|
|
869
|
+
const raw = await gh([
|
|
870
|
+
"api",
|
|
871
|
+
"graphql",
|
|
872
|
+
"-f",
|
|
873
|
+
`owner=${pr.owner}`,
|
|
874
|
+
"-f",
|
|
875
|
+
`name=${pr.repo}`,
|
|
876
|
+
"-F",
|
|
877
|
+
`number=${pr.number}`,
|
|
878
|
+
"-f",
|
|
879
|
+
`query=query($owner: String!, $name: String!, $number: Int!) {
|
|
880
|
+
repository(owner: $owner, name: $name) {
|
|
881
|
+
pullRequest(number: $number) {
|
|
882
|
+
reviewThreads(first: 100) {
|
|
883
|
+
nodes {
|
|
884
|
+
id
|
|
885
|
+
isResolved
|
|
886
|
+
comments(first: 1) {
|
|
887
|
+
nodes {
|
|
888
|
+
id
|
|
889
|
+
author { login }
|
|
890
|
+
body
|
|
891
|
+
path
|
|
892
|
+
line
|
|
893
|
+
url
|
|
894
|
+
createdAt
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}`,
|
|
902
|
+
]);
|
|
903
|
+
const data = JSON.parse(raw);
|
|
904
|
+
const threads = data.data.repository.pullRequest.reviewThreads.nodes;
|
|
905
|
+
return threads
|
|
906
|
+
.filter((t) => {
|
|
907
|
+
if (t.isResolved)
|
|
908
|
+
return false; // only pending (unresolved) threads
|
|
909
|
+
const c = t.comments.nodes[0];
|
|
910
|
+
if (!c)
|
|
911
|
+
return false; // skip threads with no comments
|
|
912
|
+
const author = c.author?.login ?? "";
|
|
913
|
+
return !BOT_AUTHORS.has(author);
|
|
914
|
+
})
|
|
915
|
+
.map((t) => {
|
|
916
|
+
const c = t.comments.nodes[0];
|
|
917
|
+
return {
|
|
918
|
+
id: c.id,
|
|
919
|
+
threadId: t.id,
|
|
920
|
+
author: c.author?.login ?? "unknown",
|
|
921
|
+
body: c.body,
|
|
922
|
+
path: c.path || undefined,
|
|
923
|
+
line: c.line ?? undefined,
|
|
924
|
+
isResolved: t.isResolved,
|
|
925
|
+
createdAt: parseDate(c.createdAt),
|
|
926
|
+
url: c.url,
|
|
927
|
+
};
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
throw new Error("Failed to fetch pending comments", { cause: err });
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
async getReviewThreads(pr) {
|
|
936
|
+
const cacheKey = `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
937
|
+
// Guard 3: check if review comments changed via REST ETag
|
|
938
|
+
const reviewsChanged = await checkReviewCommentsETag(pr.owner, pr.repo, pr.number, instanceObserver);
|
|
939
|
+
if (!reviewsChanged) {
|
|
940
|
+
const cached = reviewThreadsCache.get(cacheKey);
|
|
941
|
+
if (cached)
|
|
942
|
+
return cached;
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
const rawWithHeaders = await gh([
|
|
946
|
+
"api",
|
|
947
|
+
"graphql",
|
|
948
|
+
"-i",
|
|
949
|
+
"-f",
|
|
950
|
+
`owner=${pr.owner}`,
|
|
951
|
+
"-f",
|
|
952
|
+
`name=${pr.repo}`,
|
|
953
|
+
"-F",
|
|
954
|
+
`number=${pr.number}`,
|
|
955
|
+
"-f",
|
|
956
|
+
`query=query($owner: String!, $name: String!, $number: Int!) {
|
|
957
|
+
repository(owner: $owner, name: $name) {
|
|
958
|
+
pullRequest(number: $number) {
|
|
959
|
+
reviewThreads(last: 100) {
|
|
960
|
+
nodes {
|
|
961
|
+
id
|
|
962
|
+
isResolved
|
|
963
|
+
comments(first: 1) {
|
|
964
|
+
nodes {
|
|
965
|
+
id
|
|
966
|
+
author { login }
|
|
967
|
+
body
|
|
968
|
+
path
|
|
969
|
+
line
|
|
970
|
+
url
|
|
971
|
+
createdAt
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
reviews(last: 5) {
|
|
977
|
+
nodes {
|
|
978
|
+
author { login }
|
|
979
|
+
state
|
|
980
|
+
body
|
|
981
|
+
submittedAt
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
rateLimit { cost remaining resetAt }
|
|
987
|
+
}`,
|
|
988
|
+
]);
|
|
989
|
+
// Strip HTTP headers from -i response to get JSON body
|
|
990
|
+
const raw = rawWithHeaders.replace(/^[\s\S]*?\r?\n\r?\n/, "");
|
|
991
|
+
const data = JSON.parse(raw);
|
|
992
|
+
const threadNodes = data.data.repository.pullRequest.reviewThreads.nodes;
|
|
993
|
+
const reviewNodes = data.data.repository.pullRequest.reviews.nodes;
|
|
994
|
+
const threads = threadNodes
|
|
995
|
+
.filter((t) => {
|
|
996
|
+
if (t.isResolved)
|
|
997
|
+
return false;
|
|
998
|
+
const c = t.comments.nodes[0];
|
|
999
|
+
return !!c;
|
|
1000
|
+
})
|
|
1001
|
+
.map((t) => {
|
|
1002
|
+
const c = t.comments.nodes[0];
|
|
1003
|
+
const author = c.author?.login ?? "unknown";
|
|
1004
|
+
return {
|
|
1005
|
+
id: c.id,
|
|
1006
|
+
threadId: t.id,
|
|
1007
|
+
author,
|
|
1008
|
+
body: c.body,
|
|
1009
|
+
path: c.path || undefined,
|
|
1010
|
+
line: c.line ?? undefined,
|
|
1011
|
+
isResolved: t.isResolved,
|
|
1012
|
+
createdAt: parseDate(c.createdAt),
|
|
1013
|
+
url: c.url,
|
|
1014
|
+
isBot: BOT_AUTHORS.has(author),
|
|
1015
|
+
};
|
|
1016
|
+
});
|
|
1017
|
+
const reviews = reviewNodes
|
|
1018
|
+
.filter((r) => r.body && r.body.trim().length > 0)
|
|
1019
|
+
.map((r) => ({
|
|
1020
|
+
author: r.author?.login ?? "unknown",
|
|
1021
|
+
state: r.state,
|
|
1022
|
+
body: r.body,
|
|
1023
|
+
submittedAt: parseDate(r.submittedAt),
|
|
1024
|
+
}));
|
|
1025
|
+
const result = { threads, reviews };
|
|
1026
|
+
reviewThreadsCache.set(cacheKey, result);
|
|
1027
|
+
return result;
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1031
|
+
instanceObserver?.log("warn", `[getReviewThreads] Failed for ${cacheKey}: ${errorMsg}`);
|
|
1032
|
+
throw new Error("Failed to fetch review threads", { cause: err });
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
async getMergeability(pr) {
|
|
1036
|
+
// 5s TTL — composite merge readiness. Internal getPRState/getCISummary
|
|
1037
|
+
// calls are also cached (5s each) so even on cache miss this is cheap.
|
|
1038
|
+
// Cached entry covers the full computed result so duplicate poll-cycle
|
|
1039
|
+
// calls don't re-derive blockers.
|
|
1040
|
+
return withPRCache(pr.owner, pr.repo, String(pr.number), "getMergeability", async () => {
|
|
1041
|
+
const blockers = [];
|
|
1042
|
+
// First, check if the PR is merged
|
|
1043
|
+
// GitHub returns mergeable=null for merged PRs, which is not useful
|
|
1044
|
+
// Note: We only skip checks for merged PRs. Closed PRs still need accurate status.
|
|
1045
|
+
const state = await this.getPRState(pr);
|
|
1046
|
+
if (state === "merged") {
|
|
1047
|
+
// For merged PRs, return a clean result without querying mergeable status
|
|
1048
|
+
return {
|
|
1049
|
+
mergeable: true,
|
|
1050
|
+
ciPassing: true,
|
|
1051
|
+
approved: true,
|
|
1052
|
+
noConflicts: true,
|
|
1053
|
+
blockers: [],
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
// Fetch PR details with merge state
|
|
1057
|
+
const raw = await gh([
|
|
1058
|
+
"pr",
|
|
1059
|
+
"view",
|
|
1060
|
+
String(pr.number),
|
|
1061
|
+
"--repo",
|
|
1062
|
+
repoFlag(pr),
|
|
1063
|
+
"--json",
|
|
1064
|
+
"mergeable,reviewDecision,mergeStateStatus,isDraft",
|
|
1065
|
+
]);
|
|
1066
|
+
const data = JSON.parse(raw);
|
|
1067
|
+
// CI
|
|
1068
|
+
const ciStatus = await this.getCISummary(pr);
|
|
1069
|
+
const ciPassing = ciStatus === CI_STATUS.PASSING || ciStatus === CI_STATUS.NONE;
|
|
1070
|
+
if (!ciPassing) {
|
|
1071
|
+
blockers.push(`CI is ${ciStatus}`);
|
|
1072
|
+
}
|
|
1073
|
+
// Reviews
|
|
1074
|
+
const reviewDecision = (data.reviewDecision ?? "").toUpperCase();
|
|
1075
|
+
const approved = reviewDecision === "APPROVED";
|
|
1076
|
+
if (reviewDecision === "CHANGES_REQUESTED") {
|
|
1077
|
+
blockers.push("Changes requested in review");
|
|
1078
|
+
}
|
|
1079
|
+
else if (reviewDecision === "REVIEW_REQUIRED") {
|
|
1080
|
+
blockers.push("Review required");
|
|
1081
|
+
}
|
|
1082
|
+
// Conflicts / merge state
|
|
1083
|
+
const mergeable = (data.mergeable ?? "").toUpperCase();
|
|
1084
|
+
const mergeState = (data.mergeStateStatus ?? "").toUpperCase();
|
|
1085
|
+
const noConflicts = mergeable === "MERGEABLE";
|
|
1086
|
+
if (mergeable === "CONFLICTING") {
|
|
1087
|
+
blockers.push("Merge conflicts");
|
|
1088
|
+
}
|
|
1089
|
+
else if (mergeable === "UNKNOWN" || mergeable === "") {
|
|
1090
|
+
blockers.push("Merge status unknown (GitHub is computing)");
|
|
1091
|
+
}
|
|
1092
|
+
if (mergeState === "BEHIND") {
|
|
1093
|
+
blockers.push("Branch is behind base branch");
|
|
1094
|
+
}
|
|
1095
|
+
else if (mergeState === "BLOCKED") {
|
|
1096
|
+
blockers.push("Merge is blocked by branch protection");
|
|
1097
|
+
}
|
|
1098
|
+
else if (mergeState === "UNSTABLE") {
|
|
1099
|
+
blockers.push("Required checks are failing");
|
|
1100
|
+
}
|
|
1101
|
+
// Draft
|
|
1102
|
+
if (data.isDraft) {
|
|
1103
|
+
blockers.push("PR is still a draft");
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
mergeable: blockers.length === 0,
|
|
1107
|
+
ciPassing,
|
|
1108
|
+
approved,
|
|
1109
|
+
noConflicts,
|
|
1110
|
+
blockers,
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
1113
|
+
},
|
|
1114
|
+
/**
|
|
1115
|
+
* Batch fetch PR data for multiple PRs using GraphQL.
|
|
1116
|
+
* This is an optimization for the orchestrator polling loop.
|
|
1117
|
+
*
|
|
1118
|
+
* Instead of making 3 separate API calls for each PR (getPRState,
|
|
1119
|
+
* getCISummary, getReviewDecision), we fetch all data for all PRs
|
|
1120
|
+
* in one GraphQL query using aliases.
|
|
1121
|
+
*
|
|
1122
|
+
* This reduces API calls from N×3 to 1 (or a few if batching needed).
|
|
1123
|
+
*/
|
|
1124
|
+
async enrichSessionsPRBatch(prs, observer, repos) {
|
|
1125
|
+
if (observer)
|
|
1126
|
+
instanceObserver = observer;
|
|
1127
|
+
const batchResult = await enrichSessionsPRBatchImpl(prs, observer, repos);
|
|
1128
|
+
return batchResult.enrichment;
|
|
1129
|
+
},
|
|
1130
|
+
async preflight(context) {
|
|
1131
|
+
// SCM is only exercised at spawn time when --claim-pr is set. Skip the
|
|
1132
|
+
// gh-auth check otherwise so spawns that don't touch PRs don't require
|
|
1133
|
+
// gh credentials. Lifecycle polling has its own auth handling.
|
|
1134
|
+
if (!context.intent.willClaimExistingPR)
|
|
1135
|
+
return;
|
|
1136
|
+
// Memoize across plugins: shares the "gh-cli-auth" cache key with
|
|
1137
|
+
// tracker-github so spawns that touch both only run gh --version + gh
|
|
1138
|
+
// auth status once total, not twice.
|
|
1139
|
+
await memoizeAsync("gh-cli-auth", async () => {
|
|
1140
|
+
try {
|
|
1141
|
+
await execFileAsync("gh", ["--version"]);
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com/");
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
await execFileAsync("gh", ["auth", "status"]);
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
},
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
// Plugin module export
|
|
1158
|
+
// ---------------------------------------------------------------------------
|
|
1159
|
+
export const manifest = {
|
|
1160
|
+
name: "github",
|
|
1161
|
+
slot: "scm",
|
|
1162
|
+
description: "SCM plugin: GitHub PRs, CI checks, reviews, merge readiness",
|
|
1163
|
+
version: "0.1.0",
|
|
1164
|
+
};
|
|
1165
|
+
export function create() {
|
|
1166
|
+
return createGitHubSCM();
|
|
1167
|
+
}
|
|
1168
|
+
export default { manifest, create };
|
|
1169
|
+
//# sourceMappingURL=index.js.map
|