@forge-glance/sdk 0.1.0
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/ActionCableClient.d.ts +41 -0
- package/dist/ActionCableClient.js +189 -0
- package/dist/GitHubProvider.d.ts +50 -0
- package/dist/GitHubProvider.js +361 -0
- package/dist/GitLabProvider.d.ts +34 -0
- package/dist/GitLabProvider.js +359 -0
- package/dist/GitProvider.d.ts +50 -0
- package/dist/GitProvider.js +12 -0
- package/dist/MRDetailFetcher.d.ts +18 -0
- package/dist/MRDetailFetcher.js +74 -0
- package/dist/NoteMutator.d.ts +37 -0
- package/dist/NoteMutator.js +54 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +972 -0
- package/dist/logger.d.ts +20 -0
- package/dist/logger.js +10 -0
- package/dist/providers.d.ts +23 -0
- package/dist/providers.js +722 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.js +0 -0
- package/package.json +38 -0
- package/src/ActionCableClient.ts +237 -0
- package/src/GitHubProvider.ts +639 -0
- package/src/GitLabProvider.ts +471 -0
- package/src/GitProvider.ts +77 -0
- package/src/MRDetailFetcher.ts +133 -0
- package/src/NoteMutator.ts +108 -0
- package/src/index.ts +54 -0
- package/src/logger.ts +26 -0
- package/src/providers.ts +40 -0
- package/src/types.ts +196 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub provider implementation (Phase C1 spike).
|
|
3
|
+
*
|
|
4
|
+
* Uses the GitHub REST API v3 to fetch pull requests, PR details, and
|
|
5
|
+
* discussions. Maps GitHub responses to the same provider-agnostic domain
|
|
6
|
+
* types used by GitLab, so the Swift client renders them identically.
|
|
7
|
+
*
|
|
8
|
+
* Auth: expects a GitHub Personal Access Token (classic or fine-grained)
|
|
9
|
+
* with `repo` scope. Passed as `Authorization: Bearer <token>`.
|
|
10
|
+
*
|
|
11
|
+
* Base URL: "https://api.github.com" for github.com; for GHES, the user
|
|
12
|
+
* provides the instance URL and we append "/api/v3".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GitProvider } from "./GitProvider.ts";
|
|
16
|
+
import type {
|
|
17
|
+
DiffStats,
|
|
18
|
+
Discussion,
|
|
19
|
+
MRDetail,
|
|
20
|
+
Note,
|
|
21
|
+
NoteAuthor,
|
|
22
|
+
NotePosition,
|
|
23
|
+
Pipeline,
|
|
24
|
+
PipelineJob,
|
|
25
|
+
PullRequest,
|
|
26
|
+
UserRef,
|
|
27
|
+
} from "./types.ts";
|
|
28
|
+
import { type ForgeLogger, noopLogger } from "./logger.ts";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// GitHub REST API response shapes (only fields we consume)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface GHUser {
|
|
35
|
+
id: number;
|
|
36
|
+
login: string;
|
|
37
|
+
name?: string | null;
|
|
38
|
+
avatar_url: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GHLabel {
|
|
42
|
+
id: number;
|
|
43
|
+
name: string;
|
|
44
|
+
color: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface GHPullRequest {
|
|
48
|
+
id: number;
|
|
49
|
+
number: number;
|
|
50
|
+
title: string;
|
|
51
|
+
body: string | null;
|
|
52
|
+
state: string; // "open" | "closed"
|
|
53
|
+
draft: boolean;
|
|
54
|
+
merged_at: string | null;
|
|
55
|
+
html_url: string;
|
|
56
|
+
created_at: string;
|
|
57
|
+
updated_at: string;
|
|
58
|
+
head: {
|
|
59
|
+
sha: string;
|
|
60
|
+
ref: string;
|
|
61
|
+
};
|
|
62
|
+
base: {
|
|
63
|
+
ref: string;
|
|
64
|
+
repo: {
|
|
65
|
+
id: number;
|
|
66
|
+
full_name: string;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
user: GHUser;
|
|
70
|
+
assignees: GHUser[];
|
|
71
|
+
requested_reviewers: GHUser[];
|
|
72
|
+
labels: GHLabel[];
|
|
73
|
+
additions?: number;
|
|
74
|
+
deletions?: number;
|
|
75
|
+
changed_files?: number;
|
|
76
|
+
mergeable?: boolean | null;
|
|
77
|
+
mergeable_state?: string; // "dirty" | "clean" | "unstable" | "blocked" | ...
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface GHReview {
|
|
81
|
+
id: number;
|
|
82
|
+
user: GHUser;
|
|
83
|
+
state: string; // "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"
|
|
84
|
+
submitted_at: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface GHCheckRun {
|
|
88
|
+
id: number;
|
|
89
|
+
name: string;
|
|
90
|
+
status: string; // "queued" | "in_progress" | "completed"
|
|
91
|
+
conclusion: string | null; // "success" | "failure" | "neutral" | "cancelled" | "timed_out" | "action_required" | "skipped" | null
|
|
92
|
+
html_url: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface GHCheckSuite {
|
|
96
|
+
check_runs: GHCheckRun[];
|
|
97
|
+
total_count: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface GHComment {
|
|
101
|
+
id: number;
|
|
102
|
+
body: string;
|
|
103
|
+
user: GHUser;
|
|
104
|
+
created_at: string;
|
|
105
|
+
path?: string | null;
|
|
106
|
+
line?: number | null;
|
|
107
|
+
original_line?: number | null;
|
|
108
|
+
diff_hunk?: string | null;
|
|
109
|
+
pull_request_review_id?: number | null;
|
|
110
|
+
in_reply_to_id?: number | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Helpers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function toUserRef(u: GHUser): UserRef {
|
|
118
|
+
return {
|
|
119
|
+
id: `github:user:${u.id}`,
|
|
120
|
+
username: u.login,
|
|
121
|
+
name: u.name ?? u.login,
|
|
122
|
+
avatarUrl: u.avatar_url,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize GitHub PR state to our domain states.
|
|
128
|
+
* GitHub only has "open" and "closed"; we check `merged_at` to distinguish merges.
|
|
129
|
+
*/
|
|
130
|
+
function normalizePRState(pr: GHPullRequest): string {
|
|
131
|
+
if (pr.merged_at) return "merged";
|
|
132
|
+
if (pr.state === "open") return "opened";
|
|
133
|
+
return "closed";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Map GitHub check runs to our Pipeline model.
|
|
138
|
+
* GitHub doesn't have a single "pipeline" concept; we synthesize one from check runs.
|
|
139
|
+
*/
|
|
140
|
+
function toPipeline(
|
|
141
|
+
checkRuns: GHCheckRun[],
|
|
142
|
+
prHtmlUrl: string,
|
|
143
|
+
): Pipeline | null {
|
|
144
|
+
if (checkRuns.length === 0) return null;
|
|
145
|
+
|
|
146
|
+
const jobs: PipelineJob[] = checkRuns.map((cr) => ({
|
|
147
|
+
id: `github:check:${cr.id}`,
|
|
148
|
+
name: cr.name,
|
|
149
|
+
stage: "checks", // GitHub doesn't have stages; use a flat stage name
|
|
150
|
+
status: normalizeCheckStatus(cr),
|
|
151
|
+
allowFailure: false,
|
|
152
|
+
webUrl: cr.html_url,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// Derive overall pipeline status from individual check runs
|
|
156
|
+
const statuses = jobs.map((j) => j.status);
|
|
157
|
+
let overallStatus: string;
|
|
158
|
+
if (statuses.some((s) => s === "failed")) {
|
|
159
|
+
overallStatus = "failed";
|
|
160
|
+
} else if (statuses.some((s) => s === "running")) {
|
|
161
|
+
overallStatus = "running";
|
|
162
|
+
} else if (statuses.some((s) => s === "pending")) {
|
|
163
|
+
overallStatus = "pending";
|
|
164
|
+
} else if (statuses.every((s) => s === "success" || s === "skipped")) {
|
|
165
|
+
overallStatus = "success";
|
|
166
|
+
} else {
|
|
167
|
+
overallStatus = "pending";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
id: `github:checks:${prHtmlUrl}`,
|
|
172
|
+
status: overallStatus,
|
|
173
|
+
createdAt: null,
|
|
174
|
+
webUrl: `${prHtmlUrl}/checks`,
|
|
175
|
+
jobs,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeCheckStatus(cr: GHCheckRun): string {
|
|
180
|
+
if (cr.status === "completed") {
|
|
181
|
+
switch (cr.conclusion) {
|
|
182
|
+
case "success":
|
|
183
|
+
return "success";
|
|
184
|
+
case "failure":
|
|
185
|
+
case "timed_out":
|
|
186
|
+
return "failed";
|
|
187
|
+
case "cancelled":
|
|
188
|
+
return "canceled";
|
|
189
|
+
case "skipped":
|
|
190
|
+
return "skipped";
|
|
191
|
+
case "neutral":
|
|
192
|
+
return "success";
|
|
193
|
+
case "action_required":
|
|
194
|
+
return "manual";
|
|
195
|
+
default:
|
|
196
|
+
return "pending";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (cr.status === "in_progress") return "running";
|
|
200
|
+
return "pending"; // "queued"
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// GitHubProvider
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export class GitHubProvider implements GitProvider {
|
|
208
|
+
readonly providerName = "github" as const;
|
|
209
|
+
readonly baseURL: string;
|
|
210
|
+
private readonly apiBase: string;
|
|
211
|
+
private readonly token: string;
|
|
212
|
+
private readonly log: ForgeLogger;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param baseURL — The user-facing GitHub URL. For github.com: "https://github.com".
|
|
216
|
+
* For GHES: "https://github.mycompany.com".
|
|
217
|
+
* @param token — A GitHub PAT (classic or fine-grained) with `repo` scope.
|
|
218
|
+
* @param options.logger — Optional logger; defaults to noop.
|
|
219
|
+
*/
|
|
220
|
+
constructor(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
|
|
221
|
+
this.baseURL = baseURL.replace(/\/$/, "");
|
|
222
|
+
this.token = token;
|
|
223
|
+
this.log = options.logger ?? noopLogger;
|
|
224
|
+
|
|
225
|
+
// API base: github.com uses api.github.com; GHES uses <host>/api/v3
|
|
226
|
+
if (
|
|
227
|
+
this.baseURL === "https://github.com" ||
|
|
228
|
+
this.baseURL === "https://www.github.com"
|
|
229
|
+
) {
|
|
230
|
+
this.apiBase = "https://api.github.com";
|
|
231
|
+
} else {
|
|
232
|
+
this.apiBase = `${this.baseURL}/api/v3`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── GitProvider interface ─────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async validateToken(): Promise<UserRef> {
|
|
239
|
+
const res = await this.api("GET", "/user");
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`GitHub token validation failed: ${res.status} ${res.statusText}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const user = (await res.json()) as GHUser;
|
|
246
|
+
return toUserRef(user);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async fetchPullRequests(): Promise<PullRequest[]> {
|
|
250
|
+
// Fetch PRs where the user is involved (author, assignee, reviewer, mentioned)
|
|
251
|
+
// The `pulls` search qualifier covers authored PRs; `review-requested` covers
|
|
252
|
+
// review requests. We merge the results.
|
|
253
|
+
|
|
254
|
+
const [authored, reviewRequested, assigned] = await Promise.all([
|
|
255
|
+
this.searchPRs("is:open is:pr author:@me"),
|
|
256
|
+
this.searchPRs("is:open is:pr review-requested:@me"),
|
|
257
|
+
this.searchPRs("is:open is:pr assignee:@me"),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
// Deduplicate by PR number+repo
|
|
261
|
+
const byKey = new Map<string, GHPullRequest>();
|
|
262
|
+
const roles = new Map<string, string[]>();
|
|
263
|
+
|
|
264
|
+
const addAll = (prs: GHPullRequest[], role: string) => {
|
|
265
|
+
for (const pr of prs) {
|
|
266
|
+
const key = `${pr.base.repo.id}:${pr.number}`;
|
|
267
|
+
if (!byKey.has(key)) {
|
|
268
|
+
byKey.set(key, pr);
|
|
269
|
+
roles.set(key, [role]);
|
|
270
|
+
} else {
|
|
271
|
+
const existing = roles.get(key)!;
|
|
272
|
+
if (!existing.includes(role)) existing.push(role);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
addAll(authored, "author");
|
|
278
|
+
addAll(reviewRequested, "reviewer");
|
|
279
|
+
addAll(assigned, "assignee");
|
|
280
|
+
|
|
281
|
+
// For each unique PR, fetch check runs and reviews in parallel
|
|
282
|
+
const entries = [...byKey.entries()];
|
|
283
|
+
const results = await Promise.all(
|
|
284
|
+
entries.map(async ([key, pr]) => {
|
|
285
|
+
const prRoles = roles.get(key) ?? ["author"];
|
|
286
|
+
const [reviews, checkRuns] = await Promise.all([
|
|
287
|
+
this.fetchReviews(pr.base.repo.full_name, pr.number),
|
|
288
|
+
this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha),
|
|
289
|
+
]);
|
|
290
|
+
return this.toPullRequest(pr, prRoles, reviews, checkRuns);
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
this.log.debug("GitHubProvider.fetchPullRequests", { count: results.length });
|
|
295
|
+
return results;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async fetchSingleMR(
|
|
299
|
+
projectPath: string,
|
|
300
|
+
mrIid: number,
|
|
301
|
+
_currentUserNumericId: number | null,
|
|
302
|
+
): Promise<PullRequest | null> {
|
|
303
|
+
// projectPath for GitHub is "owner/repo"
|
|
304
|
+
try {
|
|
305
|
+
const res = await this.api("GET", `/repos/${projectPath}/pulls/${mrIid}`);
|
|
306
|
+
if (!res.ok) return null;
|
|
307
|
+
|
|
308
|
+
const pr = (await res.json()) as GHPullRequest;
|
|
309
|
+
const [reviews, checkRuns] = await Promise.all([
|
|
310
|
+
this.fetchReviews(projectPath, mrIid),
|
|
311
|
+
this.fetchCheckRuns(projectPath, pr.head.sha),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
// Determine roles from the current user
|
|
315
|
+
const currentUser = await this.api("GET", "/user");
|
|
316
|
+
const currentUserData = (await currentUser.json()) as GHUser;
|
|
317
|
+
const prRoles: string[] = [];
|
|
318
|
+
if (pr.user.id === currentUserData.id) prRoles.push("author");
|
|
319
|
+
if (pr.assignees.some((a) => a.id === currentUserData.id))
|
|
320
|
+
prRoles.push("assignee");
|
|
321
|
+
if (pr.requested_reviewers.some((r) => r.id === currentUserData.id))
|
|
322
|
+
prRoles.push("reviewer");
|
|
323
|
+
|
|
324
|
+
return this.toPullRequest(
|
|
325
|
+
pr,
|
|
326
|
+
prRoles.length > 0 ? prRoles : ["author"],
|
|
327
|
+
reviews,
|
|
328
|
+
checkRuns,
|
|
329
|
+
);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
332
|
+
this.log.warn("GitHubProvider.fetchSingleMR failed", {
|
|
333
|
+
projectPath,
|
|
334
|
+
mrIid,
|
|
335
|
+
message,
|
|
336
|
+
});
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async fetchMRDiscussions(
|
|
342
|
+
repositoryId: string,
|
|
343
|
+
mrIid: number,
|
|
344
|
+
): Promise<MRDetail> {
|
|
345
|
+
const repoId = parseInt(repositoryId.split(":").pop() ?? "0", 10);
|
|
346
|
+
// We need the repo full_name. For now, look it up from the API.
|
|
347
|
+
const repoRes = await this.api("GET", `/repositories/${repoId}`);
|
|
348
|
+
if (!repoRes.ok) {
|
|
349
|
+
throw new Error(`Failed to fetch repo: ${repoRes.status}`);
|
|
350
|
+
}
|
|
351
|
+
const repo = (await repoRes.json()) as { full_name: string };
|
|
352
|
+
|
|
353
|
+
// Fetch review comments (diff-level) and issue comments (PR-level)
|
|
354
|
+
const [reviewComments, issueComments] = await Promise.all([
|
|
355
|
+
this.fetchAllPages<GHComment>(
|
|
356
|
+
`/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100`,
|
|
357
|
+
),
|
|
358
|
+
this.fetchAllPages<GHComment>(
|
|
359
|
+
`/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100`,
|
|
360
|
+
),
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
// Group review comments into threads (by pull_request_review_id and in_reply_to_id)
|
|
364
|
+
const discussions: Discussion[] = [];
|
|
365
|
+
|
|
366
|
+
// Issue comments become individual discussions (no threading)
|
|
367
|
+
for (const c of issueComments) {
|
|
368
|
+
discussions.push({
|
|
369
|
+
id: `gh-issue-comment-${c.id}`,
|
|
370
|
+
resolvable: null,
|
|
371
|
+
resolved: null,
|
|
372
|
+
notes: [toNote(c)],
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Group review comments by thread root
|
|
377
|
+
const threadMap = new Map<number, GHComment[]>();
|
|
378
|
+
for (const c of reviewComments) {
|
|
379
|
+
const rootId = c.in_reply_to_id ?? c.id;
|
|
380
|
+
const thread = threadMap.get(rootId) ?? [];
|
|
381
|
+
thread.push(c);
|
|
382
|
+
threadMap.set(rootId, thread);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const [rootId, comments] of threadMap) {
|
|
386
|
+
comments.sort(
|
|
387
|
+
(a, b) =>
|
|
388
|
+
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
389
|
+
);
|
|
390
|
+
discussions.push({
|
|
391
|
+
id: `gh-review-thread-${rootId}`,
|
|
392
|
+
resolvable: true,
|
|
393
|
+
resolved: null, // GitHub doesn't have a native "resolved" state on review threads
|
|
394
|
+
notes: comments.map(toNote),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { mrIid, repositoryId, discussions };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async restRequest(
|
|
402
|
+
method: string,
|
|
403
|
+
path: string,
|
|
404
|
+
body?: unknown,
|
|
405
|
+
): Promise<Response> {
|
|
406
|
+
return this.api(method, path, body);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Private helpers ─────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
private async api(
|
|
412
|
+
method: string,
|
|
413
|
+
path: string,
|
|
414
|
+
body?: unknown,
|
|
415
|
+
): Promise<Response> {
|
|
416
|
+
const url = `${this.apiBase}${path}`;
|
|
417
|
+
const headers: Record<string, string> = {
|
|
418
|
+
Authorization: `Bearer ${this.token}`,
|
|
419
|
+
Accept: "application/vnd.github+json",
|
|
420
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
421
|
+
};
|
|
422
|
+
if (body !== undefined) {
|
|
423
|
+
headers["Content-Type"] = "application/json";
|
|
424
|
+
}
|
|
425
|
+
return fetch(url, {
|
|
426
|
+
method,
|
|
427
|
+
headers,
|
|
428
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Search for PRs using the GitHub search API.
|
|
434
|
+
* Returns up to 100 results per query.
|
|
435
|
+
*/
|
|
436
|
+
private async searchPRs(qualifiers: string): Promise<GHPullRequest[]> {
|
|
437
|
+
const q = encodeURIComponent(qualifiers);
|
|
438
|
+
const res = await this.api(
|
|
439
|
+
"GET",
|
|
440
|
+
`/search/issues?q=${q}&per_page=100&sort=updated`,
|
|
441
|
+
);
|
|
442
|
+
if (!res.ok) {
|
|
443
|
+
this.log.warn("GitHub search failed", { status: res.status, qualifiers });
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const data = (await res.json()) as {
|
|
448
|
+
items: Array<{
|
|
449
|
+
number: number;
|
|
450
|
+
pull_request?: { url: string };
|
|
451
|
+
repository_url: string;
|
|
452
|
+
}>;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// The search API returns issue-shaped results; fetch full PR details
|
|
456
|
+
const prPromises = data.items
|
|
457
|
+
.filter((item) => item.pull_request) // Only PRs
|
|
458
|
+
.map(async (item) => {
|
|
459
|
+
// Extract owner/repo from repository_url
|
|
460
|
+
const repoPath = item.repository_url.replace(
|
|
461
|
+
`${this.apiBase}/repos/`,
|
|
462
|
+
"",
|
|
463
|
+
);
|
|
464
|
+
const res = await this.api(
|
|
465
|
+
"GET",
|
|
466
|
+
`/repos/${repoPath}/pulls/${item.number}`,
|
|
467
|
+
);
|
|
468
|
+
if (!res.ok) return null;
|
|
469
|
+
return (await res.json()) as GHPullRequest;
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const results = await Promise.all(prPromises);
|
|
473
|
+
return results.filter((pr): pr is GHPullRequest => pr !== null);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private async fetchReviews(
|
|
477
|
+
repoPath: string,
|
|
478
|
+
prNumber: number,
|
|
479
|
+
): Promise<GHReview[]> {
|
|
480
|
+
return this.fetchAllPages<GHReview>(
|
|
481
|
+
`/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async fetchCheckRuns(
|
|
486
|
+
repoPath: string,
|
|
487
|
+
sha: string,
|
|
488
|
+
): Promise<GHCheckRun[]> {
|
|
489
|
+
try {
|
|
490
|
+
const res = await this.api(
|
|
491
|
+
"GET",
|
|
492
|
+
`/repos/${repoPath}/commits/${sha}/check-runs?per_page=100`,
|
|
493
|
+
);
|
|
494
|
+
if (!res.ok) return [];
|
|
495
|
+
const data = (await res.json()) as GHCheckSuite;
|
|
496
|
+
return data.check_runs;
|
|
497
|
+
} catch {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private async fetchAllPages<T>(path: string): Promise<T[]> {
|
|
503
|
+
const results: T[] = [];
|
|
504
|
+
let url: string | null = path;
|
|
505
|
+
|
|
506
|
+
while (url) {
|
|
507
|
+
const res = await this.api("GET", url);
|
|
508
|
+
if (!res.ok) break;
|
|
509
|
+
const items = (await res.json()) as T[];
|
|
510
|
+
results.push(...items);
|
|
511
|
+
|
|
512
|
+
// Parse Link header for pagination
|
|
513
|
+
const linkHeader = res.headers.get("Link");
|
|
514
|
+
const nextMatch = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
|
|
515
|
+
if (nextMatch) {
|
|
516
|
+
// Strip apiBase prefix — `api()` will re-add it
|
|
517
|
+
url = nextMatch[1]!.replace(this.apiBase, "");
|
|
518
|
+
} else {
|
|
519
|
+
url = null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return results;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Convert a GitHub PR + reviews + check runs into our domain PullRequest.
|
|
528
|
+
*/
|
|
529
|
+
private toPullRequest(
|
|
530
|
+
pr: GHPullRequest,
|
|
531
|
+
roles: string[],
|
|
532
|
+
reviews: GHReview[],
|
|
533
|
+
checkRuns: GHCheckRun[],
|
|
534
|
+
): PullRequest {
|
|
535
|
+
// Compute approvals: latest review per user, count "APPROVED" ones
|
|
536
|
+
const latestReviewByUser = new Map<number, GHReview>();
|
|
537
|
+
for (const r of reviews.sort(
|
|
538
|
+
(a, b) =>
|
|
539
|
+
new Date(a.submitted_at).getTime() -
|
|
540
|
+
new Date(b.submitted_at).getTime(),
|
|
541
|
+
)) {
|
|
542
|
+
latestReviewByUser.set(r.user.id, r);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const approvedBy: UserRef[] = [];
|
|
546
|
+
let changesRequested = 0;
|
|
547
|
+
for (const r of latestReviewByUser.values()) {
|
|
548
|
+
if (r.state === "APPROVED") {
|
|
549
|
+
approvedBy.push(toUserRef(r.user));
|
|
550
|
+
} else if (r.state === "CHANGES_REQUESTED") {
|
|
551
|
+
changesRequested++;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// GitHub doesn't have an "approvals left" concept natively;
|
|
556
|
+
// we use changesRequested as a proxy (0 if no changes requested)
|
|
557
|
+
const approvalsLeft = changesRequested;
|
|
558
|
+
|
|
559
|
+
const diffStats: DiffStats | null =
|
|
560
|
+
pr.additions !== undefined
|
|
561
|
+
? {
|
|
562
|
+
additions: pr.additions!,
|
|
563
|
+
deletions: pr.deletions ?? 0,
|
|
564
|
+
filesChanged: pr.changed_files ?? 0,
|
|
565
|
+
}
|
|
566
|
+
: null;
|
|
567
|
+
|
|
568
|
+
// Conflicts: GitHub's mergeable_state "dirty" indicates conflicts
|
|
569
|
+
const conflicts =
|
|
570
|
+
pr.mergeable === false || pr.mergeable_state === "dirty";
|
|
571
|
+
|
|
572
|
+
const pipeline = toPipeline(checkRuns, pr.html_url);
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
id: `github:pr:${pr.id}`,
|
|
576
|
+
iid: pr.number,
|
|
577
|
+
repositoryId: `github:${pr.base.repo.id}`,
|
|
578
|
+
title: pr.title,
|
|
579
|
+
state: normalizePRState(pr),
|
|
580
|
+
draft: pr.draft,
|
|
581
|
+
conflicts,
|
|
582
|
+
webUrl: pr.html_url,
|
|
583
|
+
sourceBranch: pr.head.ref,
|
|
584
|
+
targetBranch: pr.base.ref,
|
|
585
|
+
createdAt: pr.created_at,
|
|
586
|
+
updatedAt: pr.updated_at,
|
|
587
|
+
sha: pr.head.sha,
|
|
588
|
+
author: toUserRef(pr.user),
|
|
589
|
+
assignees: pr.assignees.map(toUserRef),
|
|
590
|
+
reviewers: pr.requested_reviewers.map(toUserRef),
|
|
591
|
+
roles,
|
|
592
|
+
pipeline,
|
|
593
|
+
description: pr.body ?? null,
|
|
594
|
+
unresolvedThreadCount: 0, // Would need separate API call to count; keep 0 for spike
|
|
595
|
+
approvalsLeft,
|
|
596
|
+
approved: approvedBy.length > 0 && changesRequested === 0,
|
|
597
|
+
approvedBy,
|
|
598
|
+
diffStats,
|
|
599
|
+
detailedMergeStatus: null, // GitHub-specific status not applicable
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Note mapping
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
function toNote(c: GHComment): Note {
|
|
609
|
+
const position: NotePosition | null = c.path
|
|
610
|
+
? {
|
|
611
|
+
newPath: c.path,
|
|
612
|
+
oldPath: c.path,
|
|
613
|
+
newLine: c.line ?? null,
|
|
614
|
+
oldLine: c.original_line ?? null,
|
|
615
|
+
positionType: c.path ? "text" : null,
|
|
616
|
+
}
|
|
617
|
+
: null;
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
id: c.id,
|
|
621
|
+
body: c.body,
|
|
622
|
+
author: toNoteAuthor(c.user),
|
|
623
|
+
createdAt: c.created_at,
|
|
624
|
+
system: false,
|
|
625
|
+
type: c.path ? "DiffNote" : "DiscussionNote",
|
|
626
|
+
resolvable: c.path ? true : null,
|
|
627
|
+
resolved: null,
|
|
628
|
+
position,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function toNoteAuthor(u: GHUser): NoteAuthor {
|
|
633
|
+
return {
|
|
634
|
+
id: `github:user:${u.id}`,
|
|
635
|
+
username: u.login,
|
|
636
|
+
name: u.name ?? u.login,
|
|
637
|
+
avatarUrl: u.avatar_url,
|
|
638
|
+
};
|
|
639
|
+
}
|