@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,41 @@
1
+ /**
2
+ * ActionCableClient — outgoing WebSocket client for GitLab's ActionCable endpoint.
3
+ *
4
+ * Connects to wss://{baseURL}/-/cable, implements the ActionCable protocol
5
+ * (subscribe/unsubscribe/ping/confirm), and auto-reconnects with exponential backoff.
6
+ *
7
+ * Mirrors Swift's ActionCableClient.swift.
8
+ */
9
+ import { type ForgeLogger } from "./logger.ts";
10
+ export interface ActionCableCallbacks {
11
+ onConnected(): void;
12
+ onDisconnected(intentional: boolean, reason: string): void;
13
+ onMessage(identifier: string, message: unknown): void;
14
+ onConfirm(identifier: string): void;
15
+ onReject(identifier: string): void;
16
+ }
17
+ export declare class ActionCableClient {
18
+ private readonly token;
19
+ private readonly callbacks;
20
+ private ws;
21
+ private reconnectAttempt;
22
+ private intentionalDisconnect;
23
+ private reconnectTimer;
24
+ private readonly wsUrl;
25
+ private readonly originUrl;
26
+ private readonly log;
27
+ private readonly logContext;
28
+ constructor(baseURL: string, token: string, callbacks: ActionCableCallbacks, options?: {
29
+ logger?: ForgeLogger;
30
+ logContext?: string;
31
+ });
32
+ connect(): void;
33
+ disconnect(): void;
34
+ subscribe(identifier: string): void;
35
+ unsubscribe(identifier: string): void;
36
+ private performConnect;
37
+ private handleMessage;
38
+ private send;
39
+ private cleanup;
40
+ private scheduleReconnect;
41
+ }
@@ -0,0 +1,189 @@
1
+ // src/logger.ts
2
+ var noopLogger = {
3
+ debug() {},
4
+ info() {},
5
+ warn() {},
6
+ error() {}
7
+ };
8
+
9
+ // src/ActionCableClient.ts
10
+ var BASE_RECONNECT_DELAY_MS = 1000;
11
+ var MAX_RECONNECT_DELAY_MS = 120000;
12
+ var MAX_RECONNECT_ATTEMPTS = 8;
13
+
14
+ class ActionCableClient {
15
+ token;
16
+ callbacks;
17
+ ws = null;
18
+ reconnectAttempt = 0;
19
+ intentionalDisconnect = false;
20
+ reconnectTimer = null;
21
+ wsUrl;
22
+ originUrl;
23
+ log;
24
+ logContext;
25
+ constructor(baseURL, token, callbacks, options = {}) {
26
+ this.token = token;
27
+ this.callbacks = callbacks;
28
+ this.log = options.logger ?? noopLogger;
29
+ this.logContext = options.logContext ?? "";
30
+ const stripped = baseURL.replace(/\/$/, "");
31
+ this.originUrl = stripped;
32
+ this.wsUrl = stripped.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + "/-/cable";
33
+ }
34
+ connect() {
35
+ this.intentionalDisconnect = false;
36
+ this.reconnectAttempt = 0;
37
+ this.performConnect();
38
+ }
39
+ disconnect() {
40
+ this.intentionalDisconnect = true;
41
+ this.cleanup();
42
+ this.log.info("ActionCable intentionally disconnected", {
43
+ url: this.wsUrl,
44
+ ctx: this.logContext
45
+ });
46
+ }
47
+ subscribe(identifier) {
48
+ this.send({ command: "subscribe", identifier });
49
+ }
50
+ unsubscribe(identifier) {
51
+ this.send({ command: "unsubscribe", identifier });
52
+ }
53
+ performConnect() {
54
+ this.cleanup();
55
+ let ws;
56
+ try {
57
+ ws = new WebSocket(this.wsUrl, {
58
+ headers: {
59
+ Authorization: `Bearer ${this.token}`,
60
+ Origin: this.originUrl
61
+ }
62
+ });
63
+ } catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ this.log.error("ActionCable failed to create WebSocket", {
66
+ url: this.wsUrl,
67
+ message,
68
+ ctx: this.logContext
69
+ });
70
+ this.scheduleReconnect();
71
+ return;
72
+ }
73
+ this.ws = ws;
74
+ ws.onmessage = (event) => {
75
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
76
+ this.handleMessage(raw);
77
+ };
78
+ ws.onclose = (event) => {
79
+ if (this.intentionalDisconnect) {
80
+ this.callbacks.onDisconnected(true, "intentional disconnect");
81
+ } else {
82
+ const reason = event.reason || `code ${event.code}`;
83
+ this.log.warn("ActionCable disconnected", {
84
+ url: this.wsUrl,
85
+ reason,
86
+ ctx: this.logContext
87
+ });
88
+ this.callbacks.onDisconnected(false, reason);
89
+ this.scheduleReconnect();
90
+ }
91
+ };
92
+ ws.onerror = () => {
93
+ this.log.warn("ActionCable WebSocket error", { url: this.wsUrl, ctx: this.logContext });
94
+ };
95
+ this.log.info("ActionCable connecting", { url: this.wsUrl, ctx: this.logContext });
96
+ }
97
+ handleMessage(raw) {
98
+ let msg;
99
+ try {
100
+ msg = JSON.parse(raw);
101
+ } catch {
102
+ return;
103
+ }
104
+ if (!msg.type) {
105
+ if (typeof msg.identifier === "string" && msg.message !== undefined) {
106
+ this.callbacks.onMessage(msg.identifier, msg.message);
107
+ }
108
+ return;
109
+ }
110
+ switch (msg.type) {
111
+ case "welcome":
112
+ this.reconnectAttempt = 0;
113
+ this.log.info("ActionCable connected (welcome)", { url: this.wsUrl, ctx: this.logContext });
114
+ this.callbacks.onConnected();
115
+ break;
116
+ case "ping":
117
+ break;
118
+ case "confirm_subscription":
119
+ if (typeof msg.identifier === "string") {
120
+ this.log.debug("ActionCable subscription confirmed", { ctx: this.logContext });
121
+ this.callbacks.onConfirm(msg.identifier);
122
+ }
123
+ break;
124
+ case "reject_subscription":
125
+ if (typeof msg.identifier === "string") {
126
+ this.log.warn("ActionCable subscription rejected", { ctx: this.logContext });
127
+ this.callbacks.onReject(msg.identifier);
128
+ }
129
+ break;
130
+ case "disconnect": {
131
+ const shouldReconnect = msg.reconnect !== false;
132
+ this.log.info("ActionCable server disconnect", {
133
+ reason: msg.reason,
134
+ reconnect: shouldReconnect,
135
+ ctx: this.logContext
136
+ });
137
+ if (!shouldReconnect)
138
+ this.intentionalDisconnect = true;
139
+ this.callbacks.onDisconnected(!shouldReconnect, msg.reason ?? "server disconnect");
140
+ break;
141
+ }
142
+ }
143
+ }
144
+ send(obj) {
145
+ if (this.ws?.readyState === WebSocket.OPEN) {
146
+ this.ws.send(JSON.stringify(obj));
147
+ }
148
+ }
149
+ cleanup() {
150
+ if (this.reconnectTimer !== null) {
151
+ clearTimeout(this.reconnectTimer);
152
+ this.reconnectTimer = null;
153
+ }
154
+ if (this.ws !== null) {
155
+ this.ws.onmessage = null;
156
+ this.ws.onclose = null;
157
+ this.ws.onerror = null;
158
+ this.ws.close();
159
+ this.ws = null;
160
+ }
161
+ }
162
+ scheduleReconnect() {
163
+ if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
164
+ this.log.error("ActionCable max reconnect attempts reached", {
165
+ url: this.wsUrl,
166
+ ctx: this.logContext
167
+ });
168
+ return;
169
+ }
170
+ const base = BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempt);
171
+ const jitter = Math.random() * base * 0.3;
172
+ const delayMs = Math.min(base + jitter, MAX_RECONNECT_DELAY_MS);
173
+ this.reconnectAttempt++;
174
+ this.log.info("ActionCable scheduling reconnect", {
175
+ attempt: this.reconnectAttempt,
176
+ delayMs: Math.round(delayMs),
177
+ ctx: this.logContext
178
+ });
179
+ this.reconnectTimer = setTimeout(() => {
180
+ this.reconnectTimer = null;
181
+ if (!this.intentionalDisconnect) {
182
+ this.performConnect();
183
+ }
184
+ }, delayMs);
185
+ }
186
+ }
187
+ export {
188
+ ActionCableClient
189
+ };
@@ -0,0 +1,50 @@
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
+ import type { GitProvider } from "./GitProvider.ts";
15
+ import type { MRDetail, PullRequest, UserRef } from "./types.ts";
16
+ import { type ForgeLogger } from "./logger.ts";
17
+ export declare class GitHubProvider implements GitProvider {
18
+ readonly providerName: "github";
19
+ readonly baseURL: string;
20
+ private readonly apiBase;
21
+ private readonly token;
22
+ private readonly log;
23
+ /**
24
+ * @param baseURL — The user-facing GitHub URL. For github.com: "https://github.com".
25
+ * For GHES: "https://github.mycompany.com".
26
+ * @param token — A GitHub PAT (classic or fine-grained) with `repo` scope.
27
+ * @param options.logger — Optional logger; defaults to noop.
28
+ */
29
+ constructor(baseURL: string, token: string, options?: {
30
+ logger?: ForgeLogger;
31
+ });
32
+ validateToken(): Promise<UserRef>;
33
+ fetchPullRequests(): Promise<PullRequest[]>;
34
+ fetchSingleMR(projectPath: string, mrIid: number, _currentUserNumericId: number | null): Promise<PullRequest | null>;
35
+ fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail>;
36
+ restRequest(method: string, path: string, body?: unknown): Promise<Response>;
37
+ private api;
38
+ /**
39
+ * Search for PRs using the GitHub search API.
40
+ * Returns up to 100 results per query.
41
+ */
42
+ private searchPRs;
43
+ private fetchReviews;
44
+ private fetchCheckRuns;
45
+ private fetchAllPages;
46
+ /**
47
+ * Convert a GitHub PR + reviews + check runs into our domain PullRequest.
48
+ */
49
+ private toPullRequest;
50
+ }
@@ -0,0 +1,361 @@
1
+ // src/logger.ts
2
+ var noopLogger = {
3
+ debug() {},
4
+ info() {},
5
+ warn() {},
6
+ error() {}
7
+ };
8
+
9
+ // src/GitHubProvider.ts
10
+ function toUserRef(u) {
11
+ return {
12
+ id: `github:user:${u.id}`,
13
+ username: u.login,
14
+ name: u.name ?? u.login,
15
+ avatarUrl: u.avatar_url
16
+ };
17
+ }
18
+ function normalizePRState(pr) {
19
+ if (pr.merged_at)
20
+ return "merged";
21
+ if (pr.state === "open")
22
+ return "opened";
23
+ return "closed";
24
+ }
25
+ function toPipeline(checkRuns, prHtmlUrl) {
26
+ if (checkRuns.length === 0)
27
+ return null;
28
+ const jobs = checkRuns.map((cr) => ({
29
+ id: `github:check:${cr.id}`,
30
+ name: cr.name,
31
+ stage: "checks",
32
+ status: normalizeCheckStatus(cr),
33
+ allowFailure: false,
34
+ webUrl: cr.html_url
35
+ }));
36
+ const statuses = jobs.map((j) => j.status);
37
+ let overallStatus;
38
+ if (statuses.some((s) => s === "failed")) {
39
+ overallStatus = "failed";
40
+ } else if (statuses.some((s) => s === "running")) {
41
+ overallStatus = "running";
42
+ } else if (statuses.some((s) => s === "pending")) {
43
+ overallStatus = "pending";
44
+ } else if (statuses.every((s) => s === "success" || s === "skipped")) {
45
+ overallStatus = "success";
46
+ } else {
47
+ overallStatus = "pending";
48
+ }
49
+ return {
50
+ id: `github:checks:${prHtmlUrl}`,
51
+ status: overallStatus,
52
+ createdAt: null,
53
+ webUrl: `${prHtmlUrl}/checks`,
54
+ jobs
55
+ };
56
+ }
57
+ function normalizeCheckStatus(cr) {
58
+ if (cr.status === "completed") {
59
+ switch (cr.conclusion) {
60
+ case "success":
61
+ return "success";
62
+ case "failure":
63
+ case "timed_out":
64
+ return "failed";
65
+ case "cancelled":
66
+ return "canceled";
67
+ case "skipped":
68
+ return "skipped";
69
+ case "neutral":
70
+ return "success";
71
+ case "action_required":
72
+ return "manual";
73
+ default:
74
+ return "pending";
75
+ }
76
+ }
77
+ if (cr.status === "in_progress")
78
+ return "running";
79
+ return "pending";
80
+ }
81
+
82
+ class GitHubProvider {
83
+ providerName = "github";
84
+ baseURL;
85
+ apiBase;
86
+ token;
87
+ log;
88
+ constructor(baseURL, token, options = {}) {
89
+ this.baseURL = baseURL.replace(/\/$/, "");
90
+ this.token = token;
91
+ this.log = options.logger ?? noopLogger;
92
+ if (this.baseURL === "https://github.com" || this.baseURL === "https://www.github.com") {
93
+ this.apiBase = "https://api.github.com";
94
+ } else {
95
+ this.apiBase = `${this.baseURL}/api/v3`;
96
+ }
97
+ }
98
+ async validateToken() {
99
+ const res = await this.api("GET", "/user");
100
+ if (!res.ok) {
101
+ throw new Error(`GitHub token validation failed: ${res.status} ${res.statusText}`);
102
+ }
103
+ const user = await res.json();
104
+ return toUserRef(user);
105
+ }
106
+ async fetchPullRequests() {
107
+ const [authored, reviewRequested, assigned] = await Promise.all([
108
+ this.searchPRs("is:open is:pr author:@me"),
109
+ this.searchPRs("is:open is:pr review-requested:@me"),
110
+ this.searchPRs("is:open is:pr assignee:@me")
111
+ ]);
112
+ const byKey = new Map;
113
+ const roles = new Map;
114
+ const addAll = (prs, role) => {
115
+ for (const pr of prs) {
116
+ const key = `${pr.base.repo.id}:${pr.number}`;
117
+ if (!byKey.has(key)) {
118
+ byKey.set(key, pr);
119
+ roles.set(key, [role]);
120
+ } else {
121
+ const existing = roles.get(key);
122
+ if (!existing.includes(role))
123
+ existing.push(role);
124
+ }
125
+ }
126
+ };
127
+ addAll(authored, "author");
128
+ addAll(reviewRequested, "reviewer");
129
+ addAll(assigned, "assignee");
130
+ const entries = [...byKey.entries()];
131
+ const results = await Promise.all(entries.map(async ([key, pr]) => {
132
+ const prRoles = roles.get(key) ?? ["author"];
133
+ const [reviews, checkRuns] = await Promise.all([
134
+ this.fetchReviews(pr.base.repo.full_name, pr.number),
135
+ this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha)
136
+ ]);
137
+ return this.toPullRequest(pr, prRoles, reviews, checkRuns);
138
+ }));
139
+ this.log.debug("GitHubProvider.fetchPullRequests", { count: results.length });
140
+ return results;
141
+ }
142
+ async fetchSingleMR(projectPath, mrIid, _currentUserNumericId) {
143
+ try {
144
+ const res = await this.api("GET", `/repos/${projectPath}/pulls/${mrIid}`);
145
+ if (!res.ok)
146
+ return null;
147
+ const pr = await res.json();
148
+ const [reviews, checkRuns] = await Promise.all([
149
+ this.fetchReviews(projectPath, mrIid),
150
+ this.fetchCheckRuns(projectPath, pr.head.sha)
151
+ ]);
152
+ const currentUser = await this.api("GET", "/user");
153
+ const currentUserData = await currentUser.json();
154
+ const prRoles = [];
155
+ if (pr.user.id === currentUserData.id)
156
+ prRoles.push("author");
157
+ if (pr.assignees.some((a) => a.id === currentUserData.id))
158
+ prRoles.push("assignee");
159
+ if (pr.requested_reviewers.some((r) => r.id === currentUserData.id))
160
+ prRoles.push("reviewer");
161
+ return this.toPullRequest(pr, prRoles.length > 0 ? prRoles : ["author"], reviews, checkRuns);
162
+ } catch (err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ this.log.warn("GitHubProvider.fetchSingleMR failed", {
165
+ projectPath,
166
+ mrIid,
167
+ message
168
+ });
169
+ return null;
170
+ }
171
+ }
172
+ async fetchMRDiscussions(repositoryId, mrIid) {
173
+ const repoId = parseInt(repositoryId.split(":").pop() ?? "0", 10);
174
+ const repoRes = await this.api("GET", `/repositories/${repoId}`);
175
+ if (!repoRes.ok) {
176
+ throw new Error(`Failed to fetch repo: ${repoRes.status}`);
177
+ }
178
+ const repo = await repoRes.json();
179
+ const [reviewComments, issueComments] = await Promise.all([
180
+ this.fetchAllPages(`/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100`),
181
+ this.fetchAllPages(`/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100`)
182
+ ]);
183
+ const discussions = [];
184
+ for (const c of issueComments) {
185
+ discussions.push({
186
+ id: `gh-issue-comment-${c.id}`,
187
+ resolvable: null,
188
+ resolved: null,
189
+ notes: [toNote(c)]
190
+ });
191
+ }
192
+ const threadMap = new Map;
193
+ for (const c of reviewComments) {
194
+ const rootId = c.in_reply_to_id ?? c.id;
195
+ const thread = threadMap.get(rootId) ?? [];
196
+ thread.push(c);
197
+ threadMap.set(rootId, thread);
198
+ }
199
+ for (const [rootId, comments] of threadMap) {
200
+ comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
201
+ discussions.push({
202
+ id: `gh-review-thread-${rootId}`,
203
+ resolvable: true,
204
+ resolved: null,
205
+ notes: comments.map(toNote)
206
+ });
207
+ }
208
+ return { mrIid, repositoryId, discussions };
209
+ }
210
+ async restRequest(method, path, body) {
211
+ return this.api(method, path, body);
212
+ }
213
+ async api(method, path, body) {
214
+ const url = `${this.apiBase}${path}`;
215
+ const headers = {
216
+ Authorization: `Bearer ${this.token}`,
217
+ Accept: "application/vnd.github+json",
218
+ "X-GitHub-Api-Version": "2022-11-28"
219
+ };
220
+ if (body !== undefined) {
221
+ headers["Content-Type"] = "application/json";
222
+ }
223
+ return fetch(url, {
224
+ method,
225
+ headers,
226
+ body: body !== undefined ? JSON.stringify(body) : undefined
227
+ });
228
+ }
229
+ async searchPRs(qualifiers) {
230
+ const q = encodeURIComponent(qualifiers);
231
+ const res = await this.api("GET", `/search/issues?q=${q}&per_page=100&sort=updated`);
232
+ if (!res.ok) {
233
+ this.log.warn("GitHub search failed", { status: res.status, qualifiers });
234
+ return [];
235
+ }
236
+ const data = await res.json();
237
+ const prPromises = data.items.filter((item) => item.pull_request).map(async (item) => {
238
+ const repoPath = item.repository_url.replace(`${this.apiBase}/repos/`, "");
239
+ const res2 = await this.api("GET", `/repos/${repoPath}/pulls/${item.number}`);
240
+ if (!res2.ok)
241
+ return null;
242
+ return await res2.json();
243
+ });
244
+ const results = await Promise.all(prPromises);
245
+ return results.filter((pr) => pr !== null);
246
+ }
247
+ async fetchReviews(repoPath, prNumber) {
248
+ return this.fetchAllPages(`/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100`);
249
+ }
250
+ async fetchCheckRuns(repoPath, sha) {
251
+ try {
252
+ const res = await this.api("GET", `/repos/${repoPath}/commits/${sha}/check-runs?per_page=100`);
253
+ if (!res.ok)
254
+ return [];
255
+ const data = await res.json();
256
+ return data.check_runs;
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+ async fetchAllPages(path) {
262
+ const results = [];
263
+ let url = path;
264
+ while (url) {
265
+ const res = await this.api("GET", url);
266
+ if (!res.ok)
267
+ break;
268
+ const items = await res.json();
269
+ results.push(...items);
270
+ const linkHeader = res.headers.get("Link");
271
+ const nextMatch = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
272
+ if (nextMatch) {
273
+ url = nextMatch[1].replace(this.apiBase, "");
274
+ } else {
275
+ url = null;
276
+ }
277
+ }
278
+ return results;
279
+ }
280
+ toPullRequest(pr, roles, reviews, checkRuns) {
281
+ const latestReviewByUser = new Map;
282
+ for (const r of reviews.sort((a, b) => new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime())) {
283
+ latestReviewByUser.set(r.user.id, r);
284
+ }
285
+ const approvedBy = [];
286
+ let changesRequested = 0;
287
+ for (const r of latestReviewByUser.values()) {
288
+ if (r.state === "APPROVED") {
289
+ approvedBy.push(toUserRef(r.user));
290
+ } else if (r.state === "CHANGES_REQUESTED") {
291
+ changesRequested++;
292
+ }
293
+ }
294
+ const approvalsLeft = changesRequested;
295
+ const diffStats = pr.additions !== undefined ? {
296
+ additions: pr.additions,
297
+ deletions: pr.deletions ?? 0,
298
+ filesChanged: pr.changed_files ?? 0
299
+ } : null;
300
+ const conflicts = pr.mergeable === false || pr.mergeable_state === "dirty";
301
+ const pipeline = toPipeline(checkRuns, pr.html_url);
302
+ return {
303
+ id: `github:pr:${pr.id}`,
304
+ iid: pr.number,
305
+ repositoryId: `github:${pr.base.repo.id}`,
306
+ title: pr.title,
307
+ state: normalizePRState(pr),
308
+ draft: pr.draft,
309
+ conflicts,
310
+ webUrl: pr.html_url,
311
+ sourceBranch: pr.head.ref,
312
+ targetBranch: pr.base.ref,
313
+ createdAt: pr.created_at,
314
+ updatedAt: pr.updated_at,
315
+ sha: pr.head.sha,
316
+ author: toUserRef(pr.user),
317
+ assignees: pr.assignees.map(toUserRef),
318
+ reviewers: pr.requested_reviewers.map(toUserRef),
319
+ roles,
320
+ pipeline,
321
+ description: pr.body ?? null,
322
+ unresolvedThreadCount: 0,
323
+ approvalsLeft,
324
+ approved: approvedBy.length > 0 && changesRequested === 0,
325
+ approvedBy,
326
+ diffStats,
327
+ detailedMergeStatus: null
328
+ };
329
+ }
330
+ }
331
+ function toNote(c) {
332
+ const position = c.path ? {
333
+ newPath: c.path,
334
+ oldPath: c.path,
335
+ newLine: c.line ?? null,
336
+ oldLine: c.original_line ?? null,
337
+ positionType: c.path ? "text" : null
338
+ } : null;
339
+ return {
340
+ id: c.id,
341
+ body: c.body,
342
+ author: toNoteAuthor(c.user),
343
+ createdAt: c.created_at,
344
+ system: false,
345
+ type: c.path ? "DiffNote" : "DiscussionNote",
346
+ resolvable: c.path ? true : null,
347
+ resolved: null,
348
+ position
349
+ };
350
+ }
351
+ function toNoteAuthor(u) {
352
+ return {
353
+ id: `github:user:${u.id}`,
354
+ username: u.login,
355
+ name: u.name ?? u.login,
356
+ avatarUrl: u.avatar_url
357
+ };
358
+ }
359
+ export {
360
+ GitHubProvider
361
+ };
@@ -0,0 +1,34 @@
1
+ import type { GitProvider } from "./GitProvider.ts";
2
+ import type { MRDetail, PullRequest, UserRef } from "./types.ts";
3
+ import { type ForgeLogger } from "./logger.ts";
4
+ /**
5
+ * Strips the provider prefix from a scoped repositoryId and returns the
6
+ * numeric GitLab project ID needed for REST API calls.
7
+ * e.g. "gitlab:42" → 42
8
+ */
9
+ export declare function parseGitLabRepoId(repositoryId: string): number;
10
+ export declare const MR_DASHBOARD_FRAGMENT = "\n fragment MRDashboardFields on MergeRequest {\n id iid projectId title description state draft\n sourceBranch targetBranch webUrl\n diffHeadSha\n updatedAt createdAt\n conflicts\n detailedMergeStatus\n approved\n diffStatsSummary { additions deletions fileCount }\n author { id username name avatarUrl }\n assignees(first: 20) { nodes { id username name avatarUrl } }\n reviewers(first: 20) { nodes { id username name avatarUrl } }\n approvedBy(first: 20) { nodes { id username name avatarUrl } }\n approvalsLeft\n resolvableDiscussionsCount\n resolvedDiscussionsCount\n headPipeline {\n id iid status\n createdAt\n path\n stages(first: 20) { nodes {\n name\n jobs(first: 50) { nodes {\n id name status\n allowFailure\n webPath\n stage { name }\n }}\n }}\n }\n }\n";
11
+ export declare class GitLabProvider implements GitProvider {
12
+ readonly providerName: "gitlab";
13
+ readonly baseURL: string;
14
+ private readonly token;
15
+ private readonly log;
16
+ private readonly mrDetailFetcher;
17
+ constructor(baseURL: string, token: string, options?: {
18
+ logger?: ForgeLogger;
19
+ });
20
+ validateToken(): Promise<UserRef>;
21
+ /**
22
+ * Fetch a single MR by project path and IID.
23
+ * Used by SubscriptionManager when `userMergeRequestUpdated` fires.
24
+ * Returns null if the project or MR doesn't exist.
25
+ *
26
+ * Roles are computed by matching `currentUserNumericId` against the MR's
27
+ * author, assignees, and reviewers.
28
+ */
29
+ fetchSingleMR(projectPath: string, mrIid: number, currentUserNumericId: number | null): Promise<PullRequest | null>;
30
+ fetchPullRequests(): Promise<PullRequest[]>;
31
+ fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail>;
32
+ restRequest(method: string, path: string, body?: unknown): Promise<Response>;
33
+ private runQuery;
34
+ }