@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,471 @@
1
+ import type { GitProvider } from "./GitProvider.ts";
2
+ import type {
3
+ DiffStats,
4
+ MRDetail,
5
+ Pipeline,
6
+ PipelineJob,
7
+ PullRequest,
8
+ UserRef,
9
+ } from "./types.ts";
10
+ import { type ForgeLogger, noopLogger } from "./logger.ts";
11
+ import { MRDetailFetcher } from "./MRDetailFetcher.ts";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Repository ID helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Strips the provider prefix from a scoped repositoryId and returns the
19
+ * numeric GitLab project ID needed for REST API calls.
20
+ * e.g. "gitlab:42" → 42
21
+ */
22
+ export function parseGitLabRepoId(repositoryId: string): number {
23
+ const parts = repositoryId.split(":");
24
+ return parseInt(parts.at(-1) ?? "0", 10);
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // GraphQL query (same fragment as GraphQLQueries.swift mrDashboardFieldsFragment)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const MR_DASHBOARD_FRAGMENT = `
32
+ fragment MRDashboardFields on MergeRequest {
33
+ id iid projectId title description state draft
34
+ sourceBranch targetBranch webUrl
35
+ diffHeadSha
36
+ updatedAt createdAt
37
+ conflicts
38
+ detailedMergeStatus
39
+ approved
40
+ diffStatsSummary { additions deletions fileCount }
41
+ author { id username name avatarUrl }
42
+ assignees(first: 20) { nodes { id username name avatarUrl } }
43
+ reviewers(first: 20) { nodes { id username name avatarUrl } }
44
+ approvedBy(first: 20) { nodes { id username name avatarUrl } }
45
+ approvalsLeft
46
+ resolvableDiscussionsCount
47
+ resolvedDiscussionsCount
48
+ headPipeline {
49
+ id iid status
50
+ createdAt
51
+ path
52
+ stages(first: 20) { nodes {
53
+ name
54
+ jobs(first: 50) { nodes {
55
+ id name status
56
+ allowFailure
57
+ webPath
58
+ stage { name }
59
+ }}
60
+ }}
61
+ }
62
+ }
63
+ `;
64
+
65
+ const AUTHORED_QUERY = `
66
+ query GlanceDashboardAuthored {
67
+ currentUser {
68
+ authoredMergeRequests(state: opened, first: 100) {
69
+ nodes { ...MRDashboardFields }
70
+ }
71
+ }
72
+ }
73
+ ${MR_DASHBOARD_FRAGMENT}
74
+ `;
75
+
76
+ const REVIEWING_QUERY = `
77
+ query GlanceDashboardReviewing {
78
+ currentUser {
79
+ reviewRequestedMergeRequests(state: opened, first: 100) {
80
+ nodes { ...MRDashboardFields }
81
+ }
82
+ }
83
+ }
84
+ ${MR_DASHBOARD_FRAGMENT}
85
+ `;
86
+
87
+ const ASSIGNED_QUERY = `
88
+ query GlanceDashboardAssigned {
89
+ currentUser {
90
+ assignedMergeRequests(state: opened, first: 100) {
91
+ nodes { ...MRDashboardFields }
92
+ }
93
+ }
94
+ }
95
+ ${MR_DASHBOARD_FRAGMENT}
96
+ `;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Raw GQL response shapes (only the fields we use)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ interface GQLUser {
103
+ id: string;
104
+ username: string;
105
+ name: string;
106
+ avatarUrl: string | null;
107
+ }
108
+
109
+ interface GQLJob {
110
+ id: string;
111
+ name: string;
112
+ status: string;
113
+ allowFailure: boolean;
114
+ webPath: string | null;
115
+ stage: { name: string };
116
+ }
117
+
118
+ interface GQLStage {
119
+ name: string;
120
+ jobs: { nodes: GQLJob[] };
121
+ }
122
+
123
+ interface GQLPipeline {
124
+ id: string;
125
+ status: string;
126
+ createdAt: string | null;
127
+ path: string | null;
128
+ stages: { nodes: GQLStage[] };
129
+ }
130
+
131
+ interface GQLDiffStats {
132
+ additions: number;
133
+ deletions: number;
134
+ fileCount: number;
135
+ }
136
+
137
+ interface GQLMR {
138
+ id: string;
139
+ iid: string;
140
+ projectId: number;
141
+ title: string;
142
+ description: string | null;
143
+ state: string;
144
+ draft: boolean;
145
+ sourceBranch: string;
146
+ targetBranch: string;
147
+ webUrl: string;
148
+ diffHeadSha: string | null;
149
+ updatedAt: string;
150
+ createdAt: string;
151
+ conflicts: boolean;
152
+ detailedMergeStatus: string | null;
153
+ approved: boolean;
154
+ diffStatsSummary: GQLDiffStats | null;
155
+ author: GQLUser;
156
+ assignees: { nodes: GQLUser[] };
157
+ reviewers: { nodes: GQLUser[] };
158
+ approvedBy: { nodes: GQLUser[] };
159
+ approvalsLeft: number | null;
160
+ resolvableDiscussionsCount: number | null;
161
+ resolvedDiscussionsCount: number | null;
162
+ headPipeline: GQLPipeline | null;
163
+ }
164
+
165
+ interface AuthoredResponse {
166
+ currentUser: { authoredMergeRequests: { nodes: GQLMR[] } };
167
+ }
168
+ interface ReviewingResponse {
169
+ currentUser: { reviewRequestedMergeRequests: { nodes: GQLMR[] } };
170
+ }
171
+ interface AssignedResponse {
172
+ currentUser: { assignedMergeRequests: { nodes: GQLMR[] } };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Helpers
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** Extract numeric ID from a GQL global ID like "gid://gitlab/MergeRequest/12345". */
180
+ function numericId(gid: string): number {
181
+ const parts = gid.split("/");
182
+ return parseInt(parts[parts.length - 1] ?? "0", 10);
183
+ }
184
+
185
+ /** Build a scoped domain ID from a GitLab numeric integer. */
186
+ function domainId(type: string, id: number | string): string {
187
+ return `gitlab:${type}:${id}`;
188
+ }
189
+
190
+ function toUserRef(u: GQLUser): UserRef {
191
+ return {
192
+ id: `gitlab:user:${numericId(u.id)}`,
193
+ username: u.username,
194
+ name: u.name,
195
+ avatarUrl: u.avatarUrl,
196
+ };
197
+ }
198
+
199
+
200
+ function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
201
+ const allJobs: PipelineJob[] = p.stages.nodes.flatMap((stage) =>
202
+ stage.jobs.nodes.map((job) => ({
203
+ id: `gitlab:job:${numericId(job.id)}`,
204
+ name: job.name,
205
+ stage: job.stage.name,
206
+ status: job.status,
207
+ allowFailure: job.allowFailure,
208
+ webUrl: job.webPath ? `${baseURL}${job.webPath}` : null,
209
+ })),
210
+ );
211
+
212
+ return {
213
+ id: domainId("pipeline", numericId(p.id)),
214
+ status: normalizePipelineStatus(p),
215
+ createdAt: p.createdAt,
216
+ webUrl: p.path ? `${baseURL}${p.path}` : null,
217
+ jobs: allJobs,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Mirrors PipelineStatus.successWithWarnings logic from Swift:
223
+ * if any non-allow-failure job failed → "failed"
224
+ * if any allow-failure job failed but overall status is success → "success_with_warnings"
225
+ */
226
+ function normalizePipelineStatus(p: GQLPipeline): string {
227
+ const allJobs = p.stages.nodes.flatMap((s) => s.jobs.nodes);
228
+ const hasAllowFailFailed = allJobs.some(
229
+ (j) => j.allowFailure && j.status === "failed",
230
+ );
231
+ if (p.status === "success" && hasAllowFailFailed) {
232
+ return "success_with_warnings";
233
+ }
234
+ return p.status;
235
+ }
236
+
237
+ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
238
+ const resolvable = gql.resolvableDiscussionsCount ?? 0;
239
+ const resolved = gql.resolvedDiscussionsCount ?? 0;
240
+ const unresolvedThreadCount = Math.max(0, resolvable - resolved);
241
+
242
+ const diffStats: DiffStats | null = gql.diffStatsSummary
243
+ ? {
244
+ additions: gql.diffStatsSummary.additions,
245
+ deletions: gql.diffStatsSummary.deletions,
246
+ filesChanged: gql.diffStatsSummary.fileCount,
247
+ }
248
+ : null;
249
+
250
+ return {
251
+ id: `gitlab:mr:${numericId(gql.id)}`,
252
+ iid: parseInt(gql.iid, 10),
253
+ repositoryId: `gitlab:${gql.projectId}`,
254
+ title: gql.title,
255
+ description: gql.description ?? null,
256
+ state: gql.state,
257
+ draft: gql.draft,
258
+ conflicts: gql.conflicts || gql.detailedMergeStatus === "conflict",
259
+ webUrl: gql.webUrl,
260
+ sourceBranch: gql.sourceBranch,
261
+ targetBranch: gql.targetBranch,
262
+ createdAt: gql.createdAt,
263
+ updatedAt: gql.updatedAt,
264
+ sha: gql.diffHeadSha,
265
+ author: toUserRef(gql.author),
266
+ assignees: gql.assignees.nodes.map(toUserRef),
267
+ reviewers: gql.reviewers.nodes.map(toUserRef),
268
+ roles: [role],
269
+ pipeline: gql.headPipeline ? toPipeline(gql.headPipeline, baseURL) : null,
270
+ unresolvedThreadCount,
271
+ approvalsLeft: gql.approvalsLeft ?? 0,
272
+ approved: gql.approved ?? false,
273
+ approvedBy: gql.approvedBy.nodes.map(toUserRef),
274
+ diffStats,
275
+ detailedMergeStatus: gql.detailedMergeStatus ?? null,
276
+ };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Single-MR detail query (used by SubscriptionManager on userMergeRequestUpdated)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ const MR_DETAIL_QUERY = `
284
+ query GlanceMRDetail($projectPath: ID!, $iid: String!) {
285
+ project(fullPath: $projectPath) {
286
+ mergeRequest(iid: $iid) {
287
+ ...MRDashboardFields
288
+ }
289
+ }
290
+ }
291
+ ${MR_DASHBOARD_FRAGMENT}
292
+ `;
293
+
294
+ interface MRDetailResponse {
295
+ project: { mergeRequest: GQLMR | null } | null;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // GitLabProvider
300
+ // ---------------------------------------------------------------------------
301
+
302
+ export class GitLabProvider implements GitProvider {
303
+ readonly providerName = "gitlab" as const;
304
+ readonly baseURL: string;
305
+ private readonly token: string;
306
+ private readonly log: ForgeLogger;
307
+ private readonly mrDetailFetcher: MRDetailFetcher;
308
+
309
+ constructor(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
310
+ // Strip trailing slash for consistent URL building
311
+ this.baseURL = baseURL.replace(/\/$/, "");
312
+ this.token = token;
313
+ this.log = options.logger ?? noopLogger;
314
+ this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, { logger: this.log });
315
+ }
316
+
317
+ // MARK: - GitProvider
318
+
319
+ async validateToken(): Promise<UserRef> {
320
+ const url = `${this.baseURL}/api/v4/user`;
321
+ const res = await fetch(url, {
322
+ headers: { "PRIVATE-TOKEN": this.token },
323
+ });
324
+ if (!res.ok) {
325
+ throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
326
+ }
327
+ const user = (await res.json()) as {
328
+ id: number;
329
+ username: string;
330
+ name: string;
331
+ avatar_url: string | null;
332
+ };
333
+ return {
334
+ id: `gitlab:user:${user.id}`,
335
+ username: user.username,
336
+ name: user.name,
337
+ avatarUrl: user.avatar_url,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Fetch a single MR by project path and IID.
343
+ * Used by SubscriptionManager when `userMergeRequestUpdated` fires.
344
+ * Returns null if the project or MR doesn't exist.
345
+ *
346
+ * Roles are computed by matching `currentUserNumericId` against the MR's
347
+ * author, assignees, and reviewers.
348
+ */
349
+ async fetchSingleMR(
350
+ projectPath: string,
351
+ mrIid: number,
352
+ currentUserNumericId: number | null,
353
+ ): Promise<PullRequest | null> {
354
+ let resp: MRDetailResponse;
355
+ try {
356
+ resp = await this.runQuery<MRDetailResponse>(MR_DETAIL_QUERY, {
357
+ projectPath,
358
+ iid: String(mrIid),
359
+ });
360
+ } catch (err) {
361
+ const message = err instanceof Error ? err.message : String(err);
362
+ this.log.warn("fetchSingleMR failed", { projectPath, mrIid, message });
363
+ return null;
364
+ }
365
+
366
+ const gql = resp.project?.mergeRequest;
367
+ if (!gql) return null;
368
+
369
+ // Compute roles from the fresh response.
370
+ const roles: string[] = [];
371
+ if (currentUserNumericId !== null) {
372
+ const userGqlId = `gid://gitlab/User/${currentUserNumericId}`;
373
+ if (gql.author.id === userGqlId) roles.push("author");
374
+ if (gql.assignees.nodes.some((u) => u.id === userGqlId)) roles.push("assignee");
375
+ if (gql.reviewers.nodes.some((u) => u.id === userGqlId)) roles.push("reviewer");
376
+ }
377
+
378
+ // Use "author" as the primary role for toMR, then overwrite with full computed roles.
379
+ const pr = toMR(gql, roles[0] ?? "author", this.baseURL);
380
+ pr.roles = roles.length > 0 ? roles : pr.roles;
381
+ return pr;
382
+ }
383
+
384
+ async fetchPullRequests(): Promise<PullRequest[]> {
385
+ const [authored, reviewing, assigned] = await Promise.all([
386
+ this.runQuery<AuthoredResponse>(AUTHORED_QUERY),
387
+ this.runQuery<ReviewingResponse>(REVIEWING_QUERY),
388
+ this.runQuery<AssignedResponse>(ASSIGNED_QUERY),
389
+ ]);
390
+
391
+ // Merge all three sets, deduplicating by MR global ID.
392
+ // For duplicates, accumulate all roles (a user can be both author and assignee).
393
+ const byId = new Map<string, PullRequest>();
394
+
395
+ const addAll = (mrs: GQLMR[], role: string) => {
396
+ for (const gql of mrs) {
397
+ const existing = byId.get(gql.id);
398
+ if (existing) {
399
+ if (!existing.roles.includes(role)) {
400
+ existing.roles.push(role);
401
+ }
402
+ } else {
403
+ byId.set(gql.id, toMR(gql, role, this.baseURL));
404
+ }
405
+ }
406
+ };
407
+
408
+ addAll(authored.currentUser.authoredMergeRequests.nodes, "author");
409
+ addAll(reviewing.currentUser.reviewRequestedMergeRequests.nodes, "reviewer");
410
+ addAll(assigned.currentUser.assignedMergeRequests.nodes, "assignee");
411
+
412
+ const prs = [...byId.values()];
413
+ this.log.debug("fetchPullRequests", { count: prs.length });
414
+ return prs;
415
+ }
416
+
417
+ async fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail> {
418
+ const projectId = parseGitLabRepoId(repositoryId);
419
+ return this.mrDetailFetcher.fetchDetail(projectId, mrIid);
420
+ }
421
+
422
+ async restRequest(method: string, path: string, body?: unknown): Promise<Response> {
423
+ const url = `${this.baseURL}${path}`;
424
+ const headers: Record<string, string> = {
425
+ "PRIVATE-TOKEN": this.token,
426
+ };
427
+ if (body !== undefined) {
428
+ headers["Content-Type"] = "application/json";
429
+ }
430
+ return fetch(url, {
431
+ method,
432
+ headers,
433
+ body: body !== undefined ? JSON.stringify(body) : undefined,
434
+ });
435
+ }
436
+
437
+ // MARK: - Private
438
+
439
+ private async runQuery<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
440
+ const url = `${this.baseURL}/api/graphql`;
441
+ const body = JSON.stringify({ query, variables: variables ?? {} });
442
+ const res = await fetch(url, {
443
+ method: "POST",
444
+ headers: {
445
+ "Content-Type": "application/json",
446
+ Authorization: `Bearer ${this.token}`,
447
+ },
448
+ body,
449
+ });
450
+
451
+ if (!res.ok) {
452
+ throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`);
453
+ }
454
+
455
+ const envelope = (await res.json()) as {
456
+ data?: T;
457
+ errors?: Array<{ message: string }>;
458
+ };
459
+
460
+ if (envelope.errors?.length) {
461
+ const msg = envelope.errors.map((e) => e.message).join("; ");
462
+ throw new Error(`GraphQL errors: ${msg}`);
463
+ }
464
+
465
+ if (!envelope.data) {
466
+ throw new Error("GraphQL response missing data");
467
+ }
468
+
469
+ return envelope.data;
470
+ }
471
+ }
@@ -0,0 +1,77 @@
1
+ import type { Discussion, MRDetail, PullRequest, UserRef } from "./types.ts";
2
+
3
+ /**
4
+ * Provider-agnostic interface for a Git hosting service.
5
+ *
6
+ * `GitLabProvider` implements this today; `GitHubProvider` will follow.
7
+ * `Connection` stores a `GitProvider` and uses only this interface — it never
8
+ * reaches into provider-specific internals.
9
+ */
10
+ export interface GitProvider {
11
+ /** The provider slug stored in `connected_accounts.provider`. */
12
+ readonly providerName: string;
13
+
14
+ /** The base URL for this provider instance, e.g. "https://gitlab.com". */
15
+ readonly baseURL: string;
16
+
17
+ /** Validate the stored credentials and return the authenticated user. */
18
+ validateToken(): Promise<UserRef>;
19
+
20
+ /**
21
+ * Fetch all pull/merge requests the current user is involved in
22
+ * (authored, assigned, reviewing).
23
+ */
24
+ fetchPullRequests(): Promise<PullRequest[]>;
25
+
26
+ /**
27
+ * Fetch a single MR/PR by project path and IID.
28
+ * Returns null if the project or MR doesn't exist.
29
+ * Used by SubscriptionManager for real-time update handling.
30
+ */
31
+ fetchSingleMR(
32
+ projectPath: string,
33
+ mrIid: number,
34
+ currentUserNumericId: number | null,
35
+ ): Promise<PullRequest | null>;
36
+
37
+ /**
38
+ * Fetch discussions (comments, threads) for a specific MR/PR.
39
+ * Returns the MRDetail with discussions populated.
40
+ */
41
+ fetchMRDiscussions(
42
+ repositoryId: string,
43
+ mrIid: number,
44
+ ): Promise<MRDetail>;
45
+
46
+ // ── REST pass-through (used by note mutations, job traces, etc.) ────────
47
+
48
+ /**
49
+ * Make an authenticated REST API request to the provider.
50
+ * Used for operations that don't have a typed method yet (job traces,
51
+ * pipeline retries, etc.).
52
+ *
53
+ * Implementations translate the path to the provider's API URL format.
54
+ */
55
+ restRequest(
56
+ method: string,
57
+ path: string,
58
+ body?: unknown,
59
+ ): Promise<Response>;
60
+ }
61
+
62
+ /**
63
+ * Parse the numeric project/repo ID from a scoped repositoryId string.
64
+ * e.g. "gitlab:42" → 42, "github:12345" → 12345
65
+ */
66
+ export function parseRepoId(repositoryId: string): number {
67
+ const parts = repositoryId.split(":");
68
+ return parseInt(parts.at(-1) ?? "0", 10);
69
+ }
70
+
71
+ /**
72
+ * Extract the provider prefix from a scoped repositoryId.
73
+ * e.g. "gitlab:42" → "gitlab", "github:12345" → "github"
74
+ */
75
+ export function repoIdProvider(repositoryId: string): string {
76
+ return repositoryId.split(":")[0] ?? "unknown";
77
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Fetches MR detail (discussions) from GitLab via the REST API.
3
+ *
4
+ * Accepts numeric projectId for REST URL construction; returns DomainMRDetail
5
+ * with a scoped repositoryId string (e.g. "gitlab:42").
6
+ * Endpoint: GET /api/v4/projects/:id/merge_requests/:iid/discussions?per_page=100
7
+ */
8
+
9
+ import type {
10
+ Discussion,
11
+ MRDetail,
12
+ Note,
13
+ NoteAuthor,
14
+ NotePosition,
15
+ } from "./types.ts";
16
+ import { type ForgeLogger, noopLogger } from "./logger.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Raw REST response shapes
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface RESTNoteAuthor {
23
+ id: number;
24
+ username: string;
25
+ name: string;
26
+ avatar_url: string | null;
27
+ }
28
+
29
+ interface RESTNotePosition {
30
+ old_path?: string | null;
31
+ new_path?: string | null;
32
+ old_line?: number | null;
33
+ new_line?: number | null;
34
+ position_type?: string | null;
35
+ }
36
+
37
+ interface RESTNote {
38
+ id: number;
39
+ type: string | null;
40
+ body: string;
41
+ author: RESTNoteAuthor;
42
+ created_at: string;
43
+ system: boolean;
44
+ resolvable?: boolean | null;
45
+ resolved?: boolean | null;
46
+ position?: RESTNotePosition | null;
47
+ }
48
+
49
+ interface RESTDiscussion {
50
+ id: string;
51
+ notes: RESTNote[];
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // MRDetailFetcher
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export class MRDetailFetcher {
59
+ private readonly baseURL: string;
60
+ private readonly token: string;
61
+ private readonly log: ForgeLogger;
62
+
63
+ constructor(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
64
+ this.baseURL = baseURL.replace(/\/$/, "");
65
+ this.token = token;
66
+ this.log = options.logger ?? noopLogger;
67
+ }
68
+
69
+ async fetchDetail(projectId: number, mrIid: number): Promise<MRDetail> {
70
+ const url = `${this.baseURL}/api/v4/projects/${projectId}/merge_requests/${mrIid}/discussions?per_page=100`;
71
+ const res = await fetch(url, {
72
+ headers: { "PRIVATE-TOKEN": this.token },
73
+ });
74
+
75
+ if (!res.ok) {
76
+ throw new Error(`MR discussions fetch failed: ${res.status} ${res.statusText}`);
77
+ }
78
+
79
+ const raw = (await res.json()) as RESTDiscussion[];
80
+
81
+ const discussions: Discussion[] = raw.map((d) => ({
82
+ id: d.id,
83
+ resolvable: null,
84
+ resolved: null,
85
+ notes: d.notes.map(toNote),
86
+ }));
87
+
88
+ this.log.debug("MRDetailFetcher.fetchDetail", {
89
+ projectId,
90
+ mrIid,
91
+ discussionCount: discussions.length,
92
+ });
93
+
94
+ return { mrIid, repositoryId: `gitlab:${projectId}`, discussions };
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Mapping helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function toNote(n: RESTNote): Note {
103
+ return {
104
+ id: n.id,
105
+ body: n.body,
106
+ author: toAuthor(n.author),
107
+ createdAt: n.created_at,
108
+ system: n.system,
109
+ type: n.type,
110
+ resolvable: n.resolvable ?? null,
111
+ resolved: n.resolved ?? null,
112
+ position: n.position ? toPosition(n.position) : null,
113
+ };
114
+ }
115
+
116
+ function toAuthor(a: RESTNoteAuthor): NoteAuthor {
117
+ return {
118
+ id: `gitlab:user:${a.id}`,
119
+ username: a.username,
120
+ name: a.name,
121
+ avatarUrl: a.avatar_url,
122
+ };
123
+ }
124
+
125
+ function toPosition(p: RESTNotePosition): NotePosition {
126
+ return {
127
+ newPath: p.new_path ?? null,
128
+ oldPath: p.old_path ?? null,
129
+ newLine: p.new_line ?? null,
130
+ oldLine: p.old_line ?? null,
131
+ positionType: p.position_type ?? null,
132
+ };
133
+ }