@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.
@@ -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
+ }