@the-portland-company/devnotes 0.1.0 → 0.2.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.
@@ -1,4 +1,1163 @@
1
+ // src/server/forge.ts
2
+ var DEFAULT_BASE_PATH = "/api/devnotes";
3
+ var DEVNOTES_META_MARKER = "[DEVNOTES_META:";
4
+ var DEVNOTES_DEFAULT_TYPE_NAMES = ["Bug", "Feature Request", "UI Issue", "Performance"];
5
+ var DEVNOTES_DEFAULT_TASK_LIST_NAME = "General";
6
+ var UpstreamForgeError = class extends Error {
7
+ constructor(path, baseUrl, response) {
8
+ super(`Focus Forge request failed for ${path}`);
9
+ this.name = "UpstreamForgeError";
10
+ this.path = path;
11
+ this.baseUrl = baseUrl;
12
+ this.response = response;
13
+ }
14
+ };
15
+ function coerceObject(value) {
16
+ if (value && typeof value === "object" && !Array.isArray(value)) {
17
+ return value;
18
+ }
19
+ return {};
20
+ }
21
+ function parseJsonSafe(text) {
22
+ if (!text) return null;
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+ function bytesToBase64(bytes) {
30
+ if (typeof Buffer !== "undefined") {
31
+ return Buffer.from(bytes).toString("base64");
32
+ }
33
+ let binary = "";
34
+ for (let i = 0; i < bytes.length; i += 1) {
35
+ binary += String.fromCharCode(bytes[i]);
36
+ }
37
+ return btoa(binary);
38
+ }
39
+ function base64ToBytes(value) {
40
+ if (typeof Buffer !== "undefined") {
41
+ return new Uint8Array(Buffer.from(value, "base64"));
42
+ }
43
+ const binary = atob(value);
44
+ const bytes = new Uint8Array(binary.length);
45
+ for (let i = 0; i < binary.length; i += 1) {
46
+ bytes[i] = binary.charCodeAt(i);
47
+ }
48
+ return bytes;
49
+ }
50
+ function normalizeForgeBoolean(value, fallback = false) {
51
+ if (typeof value === "boolean") return value;
52
+ if (typeof value === "string") {
53
+ const normalized = value.trim().toLowerCase();
54
+ if (normalized === "true") return true;
55
+ if (normalized === "false") return false;
56
+ }
57
+ return fallback;
58
+ }
59
+ function normalizeForgeNumber(value, fallback = 0) {
60
+ const parsed = Number(value);
61
+ return Number.isFinite(parsed) ? parsed : fallback;
62
+ }
63
+ function normalizeForgeStringArray(value) {
64
+ if (!Array.isArray(value)) return [];
65
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
66
+ }
67
+ function mapBugStatusToForge(status) {
68
+ const normalized = status.trim().toLowerCase();
69
+ if (normalized === "in progress") return "in_progress";
70
+ if (normalized === "resolved" || normalized === "closed") return "completed";
71
+ return "open";
72
+ }
73
+ function encodeDevNotesMeta(meta) {
74
+ return bytesToBase64(new TextEncoder().encode(JSON.stringify(meta)));
75
+ }
76
+ function decodeDevNotesMeta(encoded) {
77
+ try {
78
+ const text = new TextDecoder().decode(base64ToBytes(encoded));
79
+ const parsed = JSON.parse(text);
80
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+ function splitDevNotesMeta(text) {
86
+ const value = String(text || "");
87
+ const markerIndex = value.lastIndexOf(DEVNOTES_META_MARKER);
88
+ if (markerIndex === -1) {
89
+ return { body: value, meta: null };
90
+ }
91
+ const endIndex = value.indexOf("]", markerIndex);
92
+ if (endIndex === -1) {
93
+ return { body: value, meta: null };
94
+ }
95
+ const encoded = value.slice(markerIndex + DEVNOTES_META_MARKER.length, endIndex).trim();
96
+ const meta = decodeDevNotesMeta(encoded);
97
+ if (!meta) {
98
+ return { body: value, meta: null };
99
+ }
100
+ const body = value.slice(0, markerIndex).replace(/\n+$/, "");
101
+ return { body, meta };
102
+ }
103
+ function appendDevNotesMeta(text, meta) {
104
+ const body = String(text || "").trimEnd();
105
+ const marker = `${DEVNOTES_META_MARKER}${encodeDevNotesMeta(meta)}]`;
106
+ return body ? `${body}
107
+
108
+ ${marker}` : marker;
109
+ }
110
+ function parseLegacyDevNotesDescription(description) {
111
+ const legacyMarker = "\n\n---\nSource: Politogy bug report";
112
+ const index = description.indexOf(legacyMarker);
113
+ if (index === -1) {
114
+ return {
115
+ description: description.trim() || null
116
+ };
117
+ }
118
+ const leading = description.slice(0, index).trim() || null;
119
+ const detailLines = description.slice(index + "\n\n---\n".length).split("\n").map((line) => line.trim()).filter(Boolean);
120
+ const values = /* @__PURE__ */ new Map();
121
+ detailLines.forEach((line) => {
122
+ const separator = line.indexOf(":");
123
+ if (separator === -1) return;
124
+ const key = line.slice(0, separator).trim().toLowerCase();
125
+ const value = line.slice(separator + 1).trim();
126
+ if (key) values.set(key, value);
127
+ });
128
+ return {
129
+ description: leading,
130
+ page_url: values.get("page url") || "",
131
+ x_position: normalizeForgeNumber(values.get("coordinates")?.split(",")[0]?.trim(), 0),
132
+ y_position: normalizeForgeNumber(values.get("coordinates")?.split(",")[1]?.trim(), 0),
133
+ severity: values.get("severity") || "Medium",
134
+ status: values.get("status") || "Open",
135
+ types: values.get("types")?.split(",").map((item) => item.trim()).filter(Boolean) || [],
136
+ creator_name: values.get("created by") || ""
137
+ };
138
+ }
139
+ function buildDevNotesReportDescription(report) {
140
+ const description = typeof report.description === "string" ? report.description : "";
141
+ return appendDevNotesMeta(description, {
142
+ kind: "report",
143
+ version: 1,
144
+ task_list_id: String(report.task_list_id || ""),
145
+ page_url: String(report.page_url || ""),
146
+ x_position: normalizeForgeNumber(report.x_position),
147
+ y_position: normalizeForgeNumber(report.y_position),
148
+ target_selector: report.target_selector === null || report.target_selector === void 0 ? null : String(report.target_selector),
149
+ target_relative_x: report.target_relative_x === null || report.target_relative_x === void 0 ? null : normalizeForgeNumber(report.target_relative_x),
150
+ target_relative_y: report.target_relative_y === null || report.target_relative_y === void 0 ? null : normalizeForgeNumber(report.target_relative_y),
151
+ types: normalizeForgeStringArray(report.types),
152
+ severity: String(report.severity || "Medium"),
153
+ expected_behavior: report.expected_behavior === null || report.expected_behavior === void 0 ? null : String(report.expected_behavior),
154
+ actual_behavior: report.actual_behavior === null || report.actual_behavior === void 0 ? null : String(report.actual_behavior),
155
+ capture_context: report.capture_context && typeof report.capture_context === "object" ? report.capture_context : null,
156
+ status: String(report.status || "Open"),
157
+ created_by: String(report.created_by || ""),
158
+ creator_name: report.creator && typeof report.creator === "object" ? String(report.creator.full_name || "") : "",
159
+ creator_email: report.creator && typeof report.creator === "object" ? String(report.creator.email || "") : "",
160
+ assigned_to: report.assigned_to === null || report.assigned_to === void 0 ? null : String(report.assigned_to),
161
+ resolved_at: report.resolved_at === null || report.resolved_at === void 0 ? null : String(report.resolved_at),
162
+ resolved_by: report.resolved_by === null || report.resolved_by === void 0 ? null : String(report.resolved_by),
163
+ approved: normalizeForgeBoolean(report.approved),
164
+ ai_ready: normalizeForgeBoolean(report.ai_ready),
165
+ ai_description: report.ai_description === null || report.ai_description === void 0 ? null : String(report.ai_description),
166
+ response: report.response === null || report.response === void 0 ? null : String(report.response)
167
+ });
168
+ }
169
+ function isDevNotesForgeTask(task) {
170
+ const description = String(task.description || "");
171
+ const parsed = splitDevNotesMeta(description);
172
+ if (parsed.meta?.kind === "report") return true;
173
+ return description.includes("Source: Politogy bug report");
174
+ }
175
+ function extractProjectsFromPayload(payload) {
176
+ const root = coerceObject(payload);
177
+ const data = coerceObject(root.data);
178
+ const bootstrap = coerceObject(data.bootstrap);
179
+ const projectArrays = [
180
+ Array.isArray(root.projects) ? root.projects : [],
181
+ Array.isArray(data.projects) ? data.projects : [],
182
+ Array.isArray(bootstrap.projects) ? bootstrap.projects : []
183
+ ];
184
+ const seen = /* @__PURE__ */ new Set();
185
+ const projects = [];
186
+ for (const group of projectArrays) {
187
+ for (const projectRaw of group) {
188
+ const project = coerceObject(projectRaw);
189
+ const id = typeof project.id === "string" ? project.id.trim() : "";
190
+ const name = typeof project.name === "string" ? project.name.trim() : "";
191
+ if (!id || !name || seen.has(id)) continue;
192
+ seen.add(id);
193
+ const organizationId = typeof project.organization_id === "string" && project.organization_id.trim() || typeof project.organizationId === "string" && project.organizationId.trim() || void 0;
194
+ projects.push({ id, name, organizationId });
195
+ }
196
+ }
197
+ return projects;
198
+ }
199
+ function extractForgeTaskId(payload) {
200
+ const root = coerceObject(payload);
201
+ const data = coerceObject(root.data);
202
+ const task = coerceObject(root.task);
203
+ const dataTask = coerceObject(data.task);
204
+ const candidates = [root.id, task.id, data.id, dataTask.id];
205
+ const invalidSentinelValues = /* @__PURE__ */ new Set(["NOT_FOUND", "not_found", "null", "undefined", ""]);
206
+ for (const candidate of candidates) {
207
+ if (typeof candidate === "string" && candidate.trim()) {
208
+ const value = candidate.trim();
209
+ if (invalidSentinelValues.has(value)) continue;
210
+ if (value.toLowerCase().startsWith("error-")) continue;
211
+ return value;
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+ function generateDevNotesShareSlug() {
217
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 24);
218
+ }
219
+ function normalizeBasePath(basePath) {
220
+ const trimmed = (basePath || DEFAULT_BASE_PATH).trim();
221
+ if (!trimmed) return DEFAULT_BASE_PATH;
222
+ const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
223
+ return normalized.endsWith("/") && normalized !== "/" ? normalized.slice(0, -1) : normalized;
224
+ }
225
+ function normalizeBaseUrl(baseUrl) {
226
+ return baseUrl.trim().replace(/\/+$/, "");
227
+ }
228
+ function normalizeUser(user) {
229
+ if (!user?.id) return null;
230
+ return {
231
+ id: String(user.id).trim(),
232
+ email: user.email == null ? null : String(user.email).trim(),
233
+ fullName: user.fullName == null ? null : String(user.fullName).trim(),
234
+ role: user.role == null ? null : String(user.role).trim()
235
+ };
236
+ }
237
+ function toCreatorRecord(user) {
238
+ return {
239
+ id: user.id,
240
+ email: user.email == null ? null : user.email,
241
+ full_name: user.fullName == null ? null : user.fullName
242
+ };
243
+ }
244
+ async function resolveCorsHeaders(request, corsHeaders) {
245
+ const headers = new Headers();
246
+ if (!corsHeaders) return headers;
247
+ const resolved = typeof corsHeaders === "function" ? await corsHeaders(request) : corsHeaders;
248
+ new Headers(resolved).forEach((value, key) => headers.set(key, value));
249
+ return headers;
250
+ }
251
+ async function jsonResponse(request, corsHeaders, body, status = 200) {
252
+ const headers = await resolveCorsHeaders(request, corsHeaders);
253
+ headers.set("Content-Type", "application/json");
254
+ return new Response(JSON.stringify(body), { status, headers });
255
+ }
256
+ async function emptyResponse(request, corsHeaders, status = 204) {
257
+ const headers = await resolveCorsHeaders(request, corsHeaders);
258
+ return new Response(null, { status, headers });
259
+ }
260
+ async function passthroughUpstreamResponse(request, corsHeaders, error) {
261
+ const headers = await resolveCorsHeaders(request, corsHeaders);
262
+ if (error.response.contentType) {
263
+ headers.set("Content-Type", error.response.contentType);
264
+ }
265
+ if (error.response.text) {
266
+ headers.set("X-DevNotes-Upstream-Path", error.path);
267
+ headers.set("X-DevNotes-Upstream-Base-Url", error.baseUrl);
268
+ return new Response(error.response.text, {
269
+ status: error.response.status,
270
+ headers
271
+ });
272
+ }
273
+ headers.set("Content-Type", "application/json");
274
+ return new Response(
275
+ JSON.stringify({
276
+ error: "Focus Forge request failed",
277
+ path: error.path,
278
+ baseUrl: error.baseUrl,
279
+ status: error.response.status
280
+ }),
281
+ {
282
+ status: error.response.status,
283
+ headers
284
+ }
285
+ );
286
+ }
287
+ async function readJsonBody(request) {
288
+ if (request.method === "GET" || request.method === "HEAD") return null;
289
+ const text = await request.text();
290
+ if (!text) return null;
291
+ try {
292
+ return JSON.parse(text);
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+ function parseRequestPath(pathname, basePath) {
298
+ if (pathname === basePath) return [];
299
+ if (!pathname.startsWith(`${basePath}/`)) return null;
300
+ return pathname.slice(basePath.length + 1).split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
301
+ }
302
+ function toProjectDiscovery(discovery) {
303
+ return {
304
+ path: discovery.discoveryPath,
305
+ baseUrl: discovery.resolvedBaseUrl
306
+ };
307
+ }
308
+ function buildKnownUsers(metadataComments, reports, currentUser) {
309
+ const users = /* @__PURE__ */ new Map();
310
+ users.set(currentUser.id, {
311
+ id: currentUser.id,
312
+ email: currentUser.email ?? null,
313
+ full_name: currentUser.fullName ?? null
314
+ });
315
+ reports.forEach((report) => {
316
+ users.set(report.created_by, report.creator || {
317
+ id: report.created_by,
318
+ email: null,
319
+ full_name: null
320
+ });
321
+ if (report.assigned_to && !users.has(report.assigned_to)) {
322
+ users.set(report.assigned_to, { id: report.assigned_to, email: null, full_name: null });
323
+ }
324
+ if (report.resolved_by && !users.has(report.resolved_by)) {
325
+ users.set(report.resolved_by, { id: report.resolved_by, email: null, full_name: null });
326
+ }
327
+ });
328
+ metadataComments.forEach((comment) => {
329
+ if (comment.meta.kind !== "message") return;
330
+ const authorId = String(comment.meta.authorId || comment.user_id || "").trim();
331
+ if (!authorId) return;
332
+ users.set(authorId, {
333
+ id: authorId,
334
+ email: comment.meta.authorEmail === null || comment.meta.authorEmail === void 0 ? null : String(comment.meta.authorEmail),
335
+ full_name: comment.meta.authorName === null || comment.meta.authorName === void 0 ? null : String(comment.meta.authorName)
336
+ });
337
+ });
338
+ return users;
339
+ }
340
+ async function maybeResolveUsers(resolveUsers, ids) {
341
+ if (!resolveUsers || ids.length === 0) return [];
342
+ const resolved = await resolveUsers(ids);
343
+ return resolved.filter((user) => Boolean(user?.id)).map((user) => toCreatorRecord({
344
+ id: String(user.id).trim(),
345
+ email: user.email ?? null,
346
+ fullName: user.fullName ?? null
347
+ }));
348
+ }
349
+ function pickTaskArray(payload) {
350
+ const data = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : [];
351
+ return data.map((item) => coerceObject(item));
352
+ }
353
+ function parseDevNotesProjectComment(comment) {
354
+ const id = typeof comment.id === "string" ? comment.id.trim() : "";
355
+ if (!id) return null;
356
+ const parsed = splitDevNotesMeta(String(comment.content || ""));
357
+ const kind = typeof parsed.meta?.kind === "string" ? parsed.meta.kind : "";
358
+ if (!kind) return null;
359
+ return {
360
+ id,
361
+ created_at: String(comment.created_at || (/* @__PURE__ */ new Date()).toISOString()),
362
+ updated_at: String(comment.updated_at || comment.created_at || (/* @__PURE__ */ new Date()).toISOString()),
363
+ user_id: comment.user_id === null || comment.user_id === void 0 ? null : String(comment.user_id),
364
+ author_name: comment.author_name === null || comment.author_name === void 0 ? null : String(comment.author_name),
365
+ author_email: comment.author_email === null || comment.author_email === void 0 ? null : String(comment.author_email),
366
+ body: parsed.body,
367
+ meta: parsed.meta || {}
368
+ };
369
+ }
370
+ function buildTaskListsFromMetadata(comments) {
371
+ return comments.filter((item) => item.meta.kind === "task_list").sort((a, b) => a.created_at.localeCompare(b.created_at)).map((item) => ({
372
+ id: item.id,
373
+ name: String(item.meta.name || DEVNOTES_DEFAULT_TASK_LIST_NAME),
374
+ share_slug: String(item.meta.share_slug || generateDevNotesShareSlug()),
375
+ is_default: normalizeForgeBoolean(item.meta.is_default),
376
+ created_by: item.meta.created_by === null || item.meta.created_by === void 0 ? null : String(item.meta.created_by),
377
+ created_at: String(item.meta.created_at || item.created_at),
378
+ updated_at: String(item.meta.updated_at || item.updated_at)
379
+ }));
380
+ }
381
+ function buildReportTypesFromMetadata(comments) {
382
+ return comments.filter((item) => item.meta.kind === "report_type").sort((a, b) => String(a.meta.name || "").localeCompare(String(b.meta.name || ""))).map((item) => ({
383
+ id: item.id,
384
+ name: String(item.meta.name || ""),
385
+ is_default: normalizeForgeBoolean(item.meta.is_default),
386
+ created_by: item.meta.created_by === null || item.meta.created_by === void 0 ? null : String(item.meta.created_by),
387
+ created_at: String(item.meta.created_at || item.created_at)
388
+ }));
389
+ }
390
+ function buildDevNotesReportFromForgeTask(task, overrides, defaultTaskListId) {
391
+ if (!isDevNotesForgeTask(task)) return null;
392
+ const taskId = typeof task.id === "string" ? task.id.trim() : "";
393
+ if (!taskId) return null;
394
+ const parsed = splitDevNotesMeta(String(task.description || ""));
395
+ const base = parsed.meta?.kind === "report" ? parsed.meta : parseLegacyDevNotesDescription(String(task.description || ""));
396
+ const combined = {
397
+ ...base,
398
+ ...overrides || {}
399
+ };
400
+ const creatorName = String(combined.creator_name || combined.created_by || "").trim();
401
+ const creatorEmail = String(combined.creator_email || "").trim() || null;
402
+ const createdBy = String(combined.created_by || "").trim() || `forge:${taskId}:creator`;
403
+ const taskCompleted = normalizeForgeBoolean(task.completed);
404
+ const status = String(combined.status || (taskCompleted ? "Resolved" : "Open"));
405
+ const description = overrides && Object.prototype.hasOwnProperty.call(overrides, "description") ? overrides.description === null ? null : String(overrides.description || "") : parsed.body.trim() || (base.description ? String(base.description) : null);
406
+ return {
407
+ id: taskId,
408
+ task_list_id: String(combined.task_list_id || defaultTaskListId || ""),
409
+ page_url: String(combined.page_url || ""),
410
+ x_position: normalizeForgeNumber(combined.x_position),
411
+ y_position: normalizeForgeNumber(combined.y_position),
412
+ target_selector: combined.target_selector === null || combined.target_selector === void 0 ? null : String(combined.target_selector),
413
+ target_relative_x: combined.target_relative_x === null || combined.target_relative_x === void 0 ? null : normalizeForgeNumber(combined.target_relative_x),
414
+ target_relative_y: combined.target_relative_y === null || combined.target_relative_y === void 0 ? null : normalizeForgeNumber(combined.target_relative_y),
415
+ types: normalizeForgeStringArray(combined.types),
416
+ severity: String(combined.severity || "Medium"),
417
+ title: String(overrides?.title || task.name || ""),
418
+ description,
419
+ expected_behavior: combined.expected_behavior === null || combined.expected_behavior === void 0 ? null : String(combined.expected_behavior),
420
+ actual_behavior: combined.actual_behavior === null || combined.actual_behavior === void 0 ? null : String(combined.actual_behavior),
421
+ capture_context: combined.capture_context && typeof combined.capture_context === "object" ? combined.capture_context : null,
422
+ response: combined.response === null || combined.response === void 0 ? null : String(combined.response),
423
+ status,
424
+ created_by: createdBy,
425
+ creator: {
426
+ id: createdBy,
427
+ email: creatorEmail,
428
+ full_name: creatorName || null
429
+ },
430
+ assigned_to: overrides && Object.prototype.hasOwnProperty.call(overrides, "assigned_to") ? overrides.assigned_to === null || overrides.assigned_to === void 0 ? null : String(overrides.assigned_to) : task.assigned_to === null || task.assigned_to === void 0 ? null : String(task.assigned_to),
431
+ resolved_at: combined.resolved_at === null || combined.resolved_at === void 0 ? taskCompleted ? String(task.completed_at || task.updated_at || task.created_at || "") : null : String(combined.resolved_at),
432
+ resolved_by: combined.resolved_by === null || combined.resolved_by === void 0 ? null : String(combined.resolved_by),
433
+ approved: normalizeForgeBoolean(combined.approved),
434
+ ai_ready: normalizeForgeBoolean(combined.ai_ready),
435
+ ai_description: combined.ai_description === null || combined.ai_description === void 0 ? null : String(combined.ai_description),
436
+ created_at: String(task.created_at || (/* @__PURE__ */ new Date()).toISOString()),
437
+ updated_at: String(
438
+ overrides?.updated_at || task.updated_at || task.created_at || (/* @__PURE__ */ new Date()).toISOString()
439
+ )
440
+ };
441
+ }
442
+ async function fetchFocusForge(context, path, init = {}) {
443
+ const url = `${context.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
444
+ const headers = new Headers(init.headers || {});
445
+ headers.set("Authorization", `Bearer ${context.pat}`);
446
+ if (!headers.has("Content-Type") && init.body) {
447
+ headers.set("Content-Type", "application/json");
448
+ }
449
+ const response = await context.fetchImpl(url, {
450
+ ...init,
451
+ headers
452
+ });
453
+ const text = await response.text();
454
+ return {
455
+ status: response.status,
456
+ ok: response.ok,
457
+ text,
458
+ payload: parseJsonSafe(text),
459
+ contentType: response.headers.get("content-type")
460
+ };
461
+ }
462
+ async function fetchForgeOrThrow(context, path, init = {}) {
463
+ const response = await fetchFocusForge(context, path, init);
464
+ if (!response.ok) {
465
+ throw new UpstreamForgeError(path, context.baseUrl, response);
466
+ }
467
+ return response;
468
+ }
469
+ async function discoverForgeProjects(context) {
470
+ const bootstrap = await fetchFocusForge(context, "/api/mobile/bootstrap", { method: "GET" });
471
+ if (!bootstrap.ok) {
472
+ return {
473
+ ok: false,
474
+ preferredProjectName: context.projectName,
475
+ resolvedBaseUrl: context.baseUrl,
476
+ discoveryPath: "/api/mobile/bootstrap",
477
+ response: bootstrap
478
+ };
479
+ }
480
+ const bootstrapProjects = extractProjectsFromPayload(bootstrap.payload);
481
+ if (bootstrapProjects.length > 0) {
482
+ return {
483
+ ok: true,
484
+ project: null,
485
+ matched: false,
486
+ preferredProjectName: context.projectName,
487
+ projects: bootstrapProjects,
488
+ resolvedBaseUrl: context.baseUrl,
489
+ discoveryPath: "/api/mobile/bootstrap"
490
+ };
491
+ }
492
+ const projectsResponse = await fetchFocusForge(context, "/api/mobile/projects", { method: "GET" });
493
+ if (!projectsResponse.ok) {
494
+ return {
495
+ ok: false,
496
+ preferredProjectName: context.projectName,
497
+ resolvedBaseUrl: context.baseUrl,
498
+ discoveryPath: "/api/mobile/projects",
499
+ response: projectsResponse
500
+ };
501
+ }
502
+ return {
503
+ ok: true,
504
+ project: null,
505
+ matched: false,
506
+ preferredProjectName: context.projectName,
507
+ projects: extractProjectsFromPayload(projectsResponse.payload),
508
+ resolvedBaseUrl: context.baseUrl,
509
+ discoveryPath: "/api/mobile/projects"
510
+ };
511
+ }
512
+ async function resolveForgeProject(context) {
513
+ const discovery = await discoverForgeProjects(context);
514
+ if (!discovery.ok) return discovery;
515
+ if (!context.projectName) {
516
+ return {
517
+ ...discovery,
518
+ preferredProjectName: null,
519
+ matched: false,
520
+ project: null
521
+ };
522
+ }
523
+ const matchedProject = discovery.projects.find(
524
+ (project) => project.name.trim().toLowerCase() === context.projectName.trim().toLowerCase()
525
+ );
526
+ return {
527
+ ...discovery,
528
+ preferredProjectName: context.projectName,
529
+ matched: Boolean(matchedProject),
530
+ project: matchedProject || null
531
+ };
532
+ }
533
+ async function fetchForgeTasksForProject(context, projectId) {
534
+ const response = await fetchForgeOrThrow(
535
+ context,
536
+ `/api/mobile/tasks?projectId=${encodeURIComponent(projectId)}`,
537
+ { method: "GET" }
538
+ );
539
+ return pickTaskArray(response.payload);
540
+ }
541
+ async function fetchForgeProjectComments(context, projectId) {
542
+ const response = await fetchForgeOrThrow(
543
+ context,
544
+ `/api/sync/comments?projectId=${encodeURIComponent(projectId)}`,
545
+ { method: "GET" }
546
+ );
547
+ return pickTaskArray(response.payload);
548
+ }
549
+ async function fetchForgeTaskComments(context, taskId) {
550
+ const response = await fetchForgeOrThrow(
551
+ context,
552
+ `/api/sync/comments?taskId=${encodeURIComponent(taskId)}`,
553
+ { method: "GET" }
554
+ );
555
+ return pickTaskArray(response.payload);
556
+ }
557
+ async function fetchForgeCommentById(context, commentId) {
558
+ const response = await fetchForgeOrThrow(
559
+ context,
560
+ `/api/sync/comments/${encodeURIComponent(commentId)}`,
561
+ { method: "GET" }
562
+ );
563
+ const payload = coerceObject(response.payload?.data || response.payload);
564
+ return Object.keys(payload).length > 0 ? payload : null;
565
+ }
566
+ async function createForgeProjectComment(context, projectId, body, meta) {
567
+ const response = await fetchForgeOrThrow(context, "/api/sync/comments", {
568
+ method: "POST",
569
+ body: JSON.stringify({
570
+ projectId,
571
+ content: appendDevNotesMeta(body, meta)
572
+ })
573
+ });
574
+ return coerceObject(response.payload?.data || response.payload);
575
+ }
576
+ async function updateForgeProjectComment(context, commentId, body, meta) {
577
+ const response = await fetchForgeOrThrow(
578
+ context,
579
+ `/api/sync/comments/${encodeURIComponent(commentId)}`,
580
+ {
581
+ method: "PUT",
582
+ body: JSON.stringify({
583
+ content: appendDevNotesMeta(body, meta)
584
+ })
585
+ }
586
+ );
587
+ return coerceObject(response.payload?.data || response.payload);
588
+ }
589
+ async function deleteForgeProjectComment(context, commentId) {
590
+ await fetchForgeOrThrow(context, `/api/sync/comments/${encodeURIComponent(commentId)}`, {
591
+ method: "DELETE"
592
+ });
593
+ }
594
+ async function ensureDevNotesProjectDefaults(context, projectId, user) {
595
+ let parsed = (await fetchForgeProjectComments(context, projectId)).map(parseDevNotesProjectComment).filter((item) => Boolean(item));
596
+ const typeComments = parsed.filter((item) => item.meta.kind === "report_type");
597
+ const taskListComments = parsed.filter((item) => item.meta.kind === "task_list");
598
+ if (typeComments.length === 0) {
599
+ for (const name of DEVNOTES_DEFAULT_TYPE_NAMES) {
600
+ await createForgeProjectComment(context, projectId, "", {
601
+ kind: "report_type",
602
+ name,
603
+ is_default: true,
604
+ created_by: user.id,
605
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
606
+ });
607
+ }
608
+ }
609
+ if (taskListComments.length === 0) {
610
+ await createForgeProjectComment(context, projectId, "", {
611
+ kind: "task_list",
612
+ name: DEVNOTES_DEFAULT_TASK_LIST_NAME,
613
+ share_slug: generateDevNotesShareSlug(),
614
+ is_default: true,
615
+ created_by: user.id,
616
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
617
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
618
+ });
619
+ }
620
+ if (typeComments.length === 0 || taskListComments.length === 0) {
621
+ parsed = (await fetchForgeProjectComments(context, projectId)).map(parseDevNotesProjectComment).filter((item) => Boolean(item));
622
+ }
623
+ return parsed;
624
+ }
625
+ function buildCapabilities() {
626
+ return { ai: false, appLink: true };
627
+ }
628
+ function buildAppLinkStatus(context, discovery) {
629
+ if (!context.pat) {
630
+ return {
631
+ linked: false,
632
+ projectName: context.projectName,
633
+ tokenLast4: null,
634
+ linkedAt: null,
635
+ projectMatched: false,
636
+ availableProjects: [],
637
+ projectDiscovery: null
638
+ };
639
+ }
640
+ if (!discovery.ok) {
641
+ return {
642
+ linked: true,
643
+ projectName: context.projectName,
644
+ tokenLast4: context.pat.slice(-4),
645
+ linkedAt: null,
646
+ projectMatched: false,
647
+ availableProjects: [],
648
+ projectDiscovery: toProjectDiscovery(discovery)
649
+ };
650
+ }
651
+ return {
652
+ linked: true,
653
+ projectName: context.projectName,
654
+ tokenLast4: context.pat.slice(-4),
655
+ linkedAt: null,
656
+ projectMatched: discovery.matched,
657
+ availableProjects: discovery.matched ? [] : discovery.projects,
658
+ projectDiscovery: toProjectDiscovery(discovery)
659
+ };
660
+ }
661
+ function sortCreators(creators) {
662
+ return creators.sort((left, right) => {
663
+ const a = left.full_name || left.email || left.id;
664
+ const b = right.full_name || right.email || right.id;
665
+ return a.localeCompare(b);
666
+ });
667
+ }
668
+ function createDevNotesServerHandler(options) {
669
+ const basePath = normalizeBasePath(options.basePath);
670
+ const baseUrl = normalizeBaseUrl(options.forge.baseUrl);
671
+ if (!baseUrl) {
672
+ throw new Error("DevNotes server helpers require forge.baseUrl.");
673
+ }
674
+ const fetchImpl = options.fetch ?? globalThis.fetch;
675
+ if (typeof fetchImpl !== "function") {
676
+ throw new Error("DevNotes server helpers require a fetch implementation.");
677
+ }
678
+ return async function handleDevNotesRequest(request) {
679
+ if (request.method === "OPTIONS") {
680
+ return await emptyResponse(request, options.corsHeaders);
681
+ }
682
+ const url = new URL(request.url);
683
+ const slug = parseRequestPath(url.pathname, basePath);
684
+ if (!slug) {
685
+ return await jsonResponse(request, options.corsHeaders, { error: "Not found" }, 404);
686
+ }
687
+ const user = normalizeUser(await options.getCurrentUser(request));
688
+ if (!user) {
689
+ return await jsonResponse(request, options.corsHeaders, { error: "Unauthorized" }, 401);
690
+ }
691
+ const method = request.method.toUpperCase();
692
+ const body = await readJsonBody(request) || {};
693
+ const [resource, resourceId, nested] = slug;
694
+ const forgeContext = {
695
+ baseUrl,
696
+ pat: String(options.forge.pat || "").trim(),
697
+ projectName: options.forge.projectName?.trim() || null,
698
+ fetchImpl
699
+ };
700
+ if (resource === "capabilities" && method === "GET") {
701
+ return await jsonResponse(request, options.corsHeaders, buildCapabilities());
702
+ }
703
+ if (!forgeContext.pat && resource === "app-link" && method === "GET") {
704
+ return await jsonResponse(
705
+ request,
706
+ options.corsHeaders,
707
+ buildAppLinkStatus(forgeContext, {
708
+ ok: true,
709
+ project: null,
710
+ matched: false,
711
+ preferredProjectName: forgeContext.projectName,
712
+ projects: [],
713
+ resolvedBaseUrl: forgeContext.baseUrl,
714
+ discoveryPath: null
715
+ })
716
+ );
717
+ }
718
+ if (!forgeContext.pat) {
719
+ return await jsonResponse(
720
+ request,
721
+ options.corsHeaders,
722
+ {
723
+ error: "FOCUS_FORGE_PAT is not configured."
724
+ },
725
+ 503
726
+ );
727
+ }
728
+ try {
729
+ const projectResolution = await resolveForgeProject(forgeContext);
730
+ if (resource === "app-link") {
731
+ if (method === "GET") {
732
+ return await jsonResponse(
733
+ request,
734
+ options.corsHeaders,
735
+ buildAppLinkStatus(forgeContext, projectResolution)
736
+ );
737
+ }
738
+ return await jsonResponse(
739
+ request,
740
+ options.corsHeaders,
741
+ {
742
+ error: "App-level Forge credentials are managed through server environment configuration."
743
+ },
744
+ 405
745
+ );
746
+ }
747
+ if (!projectResolution.ok) {
748
+ throw new UpstreamForgeError(
749
+ projectResolution.discoveryPath || "/api/mobile/bootstrap",
750
+ projectResolution.resolvedBaseUrl,
751
+ projectResolution.response
752
+ );
753
+ }
754
+ if (!projectResolution.project?.id) {
755
+ return await jsonResponse(
756
+ request,
757
+ options.corsHeaders,
758
+ {
759
+ error: projectResolution.preferredProjectName ? `Could not find Focus Forge project "${projectResolution.preferredProjectName}"` : "FOCUS_FORGE_PROJECT_NAME is not configured",
760
+ available_projects: projectResolution.projects
761
+ },
762
+ projectResolution.preferredProjectName ? 404 : 409
763
+ );
764
+ }
765
+ const project = projectResolution.project;
766
+ const metadataComments = await ensureDevNotesProjectDefaults(forgeContext, project.id, user);
767
+ const taskLists = buildTaskListsFromMetadata(metadataComments);
768
+ const defaultTaskListId = String(
769
+ taskLists.find((item) => item.is_default)?.id || taskLists[0]?.id || ""
770
+ );
771
+ const reportPatchById = /* @__PURE__ */ new Map();
772
+ const deletedReportIds = /* @__PURE__ */ new Set();
773
+ const readMarkers = /* @__PURE__ */ new Set();
774
+ metadataComments.forEach((comment) => {
775
+ const kind = String(comment.meta.kind || "");
776
+ const reportId = String(comment.meta.reportId || "").trim();
777
+ if (kind === "report_patch" && reportId) {
778
+ const previous = reportPatchById.get(reportId);
779
+ if (!previous || String(previous.updated_at || "") <= comment.updated_at) {
780
+ reportPatchById.set(reportId, {
781
+ ...comment.meta.report && typeof comment.meta.report === "object" ? comment.meta.report : {},
782
+ updated_at: comment.updated_at
783
+ });
784
+ }
785
+ }
786
+ if (kind === "report_deleted" && reportId) {
787
+ deletedReportIds.add(reportId);
788
+ }
789
+ if (kind === "message_read") {
790
+ const targetMessageId = String(comment.meta.messageId || "").trim();
791
+ const targetUserId = String(comment.meta.userId || "").trim();
792
+ if (targetMessageId && targetUserId === user.id) {
793
+ readMarkers.add(targetMessageId);
794
+ }
795
+ }
796
+ });
797
+ if (resource === "reports" && method === "GET" && !resourceId) {
798
+ const tasks = await fetchForgeTasksForProject(forgeContext, project.id);
799
+ const reports = tasks.map(
800
+ (task) => buildDevNotesReportFromForgeTask(
801
+ task,
802
+ reportPatchById.get(String(task.id || "").trim()) || null,
803
+ defaultTaskListId
804
+ )
805
+ ).filter((item) => Boolean(item)).filter((item) => !deletedReportIds.has(String(item.id)));
806
+ return await jsonResponse(request, options.corsHeaders, reports);
807
+ }
808
+ if (resource === "reports" && method === "POST" && !resourceId) {
809
+ const payload = {
810
+ ...body,
811
+ created_by: user.id,
812
+ creator: {
813
+ id: user.id,
814
+ email: user.email || null,
815
+ full_name: user.fullName || null
816
+ },
817
+ task_list_id: String(body.task_list_id || defaultTaskListId),
818
+ status: String(body.status || "Open")
819
+ };
820
+ const createPath = "/api/mobile/tasks";
821
+ const response = await fetchForgeOrThrow(forgeContext, createPath, {
822
+ method: "POST",
823
+ body: JSON.stringify({
824
+ name: String(payload.title || ""),
825
+ description: buildDevNotesReportDescription(payload),
826
+ project_id: project.id,
827
+ completed: mapBugStatusToForge(String(payload.status || "Open")) === "completed",
828
+ assigned_to: payload.assigned_to === null || payload.assigned_to === void 0 ? void 0 : String(payload.assigned_to)
829
+ })
830
+ });
831
+ const taskId = extractForgeTaskId(response.payload);
832
+ if (!taskId) {
833
+ throw new UpstreamForgeError(createPath, forgeContext.baseUrl, {
834
+ ...response,
835
+ status: response.status || 502,
836
+ text: response.text || JSON.stringify({
837
+ error: "Task endpoint succeeded but did not return a task id"
838
+ }),
839
+ contentType: response.contentType || "application/json"
840
+ });
841
+ }
842
+ const tasks = await fetchForgeTasksForProject(forgeContext, project.id);
843
+ const createdTask = tasks.find((task) => String(task.id || "") === taskId) || {
844
+ id: taskId,
845
+ name: payload.title,
846
+ description: buildDevNotesReportDescription(payload),
847
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
848
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
849
+ completed: false
850
+ };
851
+ const report = buildDevNotesReportFromForgeTask(createdTask, null, defaultTaskListId);
852
+ return await jsonResponse(request, options.corsHeaders, report);
853
+ }
854
+ if (resource === "reports" && resourceId && !nested && method === "PATCH") {
855
+ const reportId = decodeURIComponent(resourceId);
856
+ const tasks = await fetchForgeTasksForProject(forgeContext, project.id);
857
+ const existingTask = tasks.find((task) => String(task.id || "") === reportId);
858
+ if (!existingTask) {
859
+ return await jsonResponse(
860
+ request,
861
+ options.corsHeaders,
862
+ { error: "Bug report not found" },
863
+ 404
864
+ );
865
+ }
866
+ const existing = buildDevNotesReportFromForgeTask(
867
+ existingTask,
868
+ reportPatchById.get(reportId) || null,
869
+ defaultTaskListId
870
+ );
871
+ if (!existing) {
872
+ return await jsonResponse(
873
+ request,
874
+ options.corsHeaders,
875
+ { error: "Bug report not found" },
876
+ 404
877
+ );
878
+ }
879
+ const merged = {
880
+ ...existing,
881
+ ...body,
882
+ id: existing.id,
883
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
884
+ resolved_at: body.status === "Resolved" || body.status === "Closed" ? (/* @__PURE__ */ new Date()).toISOString() : existing.resolved_at,
885
+ resolved_by: body.status === "Resolved" || body.status === "Closed" ? body.resolved_by || user.id : existing.resolved_by
886
+ };
887
+ const existingPatch = metadataComments.find(
888
+ (comment) => comment.meta.kind === "report_patch" && String(comment.meta.reportId || "") === reportId
889
+ );
890
+ if (existingPatch) {
891
+ await updateForgeProjectComment(forgeContext, existingPatch.id, "", {
892
+ kind: "report_patch",
893
+ reportId,
894
+ report: merged
895
+ });
896
+ } else {
897
+ await createForgeProjectComment(forgeContext, project.id, "", {
898
+ kind: "report_patch",
899
+ reportId,
900
+ report: merged
901
+ });
902
+ }
903
+ return await jsonResponse(request, options.corsHeaders, merged);
904
+ }
905
+ if (resource === "reports" && resourceId && !nested && method === "DELETE") {
906
+ const reportId = decodeURIComponent(resourceId);
907
+ const existingDelete = metadataComments.find(
908
+ (comment) => comment.meta.kind === "report_deleted" && String(comment.meta.reportId || "") === reportId
909
+ );
910
+ if (existingDelete) {
911
+ await updateForgeProjectComment(forgeContext, existingDelete.id, "", {
912
+ kind: "report_deleted",
913
+ reportId,
914
+ deletedBy: user.id,
915
+ deletedAt: (/* @__PURE__ */ new Date()).toISOString()
916
+ });
917
+ } else {
918
+ await createForgeProjectComment(forgeContext, project.id, "", {
919
+ kind: "report_deleted",
920
+ reportId,
921
+ deletedBy: user.id,
922
+ deletedAt: (/* @__PURE__ */ new Date()).toISOString()
923
+ });
924
+ }
925
+ return await jsonResponse(request, options.corsHeaders, { success: true });
926
+ }
927
+ if (resource === "reports" && resourceId && nested === "messages" && method === "GET") {
928
+ const reportId = decodeURIComponent(resourceId);
929
+ const projectMessages = metadataComments.filter(
930
+ (comment) => comment.meta.kind === "message" && String(comment.meta.reportId || "") === reportId
931
+ ).sort((a, b) => a.created_at.localeCompare(b.created_at)).map((comment) => ({
932
+ id: comment.id,
933
+ bug_report_id: reportId,
934
+ author_id: String(comment.meta.authorId || comment.user_id || ""),
935
+ body: comment.body,
936
+ created_at: comment.created_at,
937
+ updated_at: comment.updated_at,
938
+ author: {
939
+ id: String(comment.meta.authorId || comment.user_id || ""),
940
+ email: comment.meta.authorEmail === null || comment.meta.authorEmail === void 0 ? null : String(comment.meta.authorEmail),
941
+ full_name: comment.meta.authorName === null || comment.meta.authorName === void 0 ? null : String(comment.meta.authorName)
942
+ }
943
+ }));
944
+ const legacyTaskMessages = (await fetchForgeTaskComments(forgeContext, reportId)).filter((comment) => String(comment.project_id || "") === "").map((comment) => ({
945
+ id: String(comment.id || ""),
946
+ bug_report_id: reportId,
947
+ author_id: String(comment.user_id || ""),
948
+ body: String(comment.content || ""),
949
+ created_at: String(comment.created_at || (/* @__PURE__ */ new Date()).toISOString()),
950
+ updated_at: String(comment.updated_at || comment.created_at || (/* @__PURE__ */ new Date()).toISOString()),
951
+ author: {
952
+ id: String(comment.user_id || ""),
953
+ email: comment.author_email === null || comment.author_email === void 0 ? null : String(comment.author_email),
954
+ full_name: comment.author_name === null || comment.author_name === void 0 ? null : String(comment.author_name)
955
+ }
956
+ }));
957
+ const merged = [...legacyTaskMessages, ...projectMessages].sort(
958
+ (a, b) => a.created_at.localeCompare(b.created_at)
959
+ );
960
+ return await jsonResponse(request, options.corsHeaders, merged);
961
+ }
962
+ if (resource === "reports" && resourceId && nested === "messages" && method === "POST") {
963
+ const reportId = decodeURIComponent(resourceId);
964
+ const created = await createForgeProjectComment(
965
+ forgeContext,
966
+ project.id,
967
+ String(body.body || "").trim(),
968
+ {
969
+ kind: "message",
970
+ reportId,
971
+ authorId: user.id,
972
+ authorEmail: user.email || null,
973
+ authorName: user.fullName || user.email || ""
974
+ }
975
+ );
976
+ const parsed = parseDevNotesProjectComment(created);
977
+ if (!parsed) {
978
+ return await jsonResponse(
979
+ request,
980
+ options.corsHeaders,
981
+ { error: "Failed to create message" },
982
+ 500
983
+ );
984
+ }
985
+ const message = {
986
+ id: parsed.id,
987
+ bug_report_id: reportId,
988
+ author_id: String(parsed.meta.authorId || user.id),
989
+ body: parsed.body,
990
+ created_at: parsed.created_at,
991
+ updated_at: parsed.updated_at,
992
+ author: {
993
+ id: String(parsed.meta.authorId || user.id),
994
+ email: parsed.meta.authorEmail === null || parsed.meta.authorEmail === void 0 ? null : String(parsed.meta.authorEmail),
995
+ full_name: parsed.meta.authorName === null || parsed.meta.authorName === void 0 ? null : String(parsed.meta.authorName)
996
+ }
997
+ };
998
+ return await jsonResponse(request, options.corsHeaders, message);
999
+ }
1000
+ if (resource === "report-types" && method === "GET" && !resourceId) {
1001
+ return await jsonResponse(
1002
+ request,
1003
+ options.corsHeaders,
1004
+ buildReportTypesFromMetadata(metadataComments)
1005
+ );
1006
+ }
1007
+ if (resource === "report-types" && method === "POST" && !resourceId) {
1008
+ const created = await createForgeProjectComment(forgeContext, project.id, "", {
1009
+ kind: "report_type",
1010
+ name: String(body.name || "").trim(),
1011
+ is_default: false,
1012
+ created_by: user.id,
1013
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1014
+ });
1015
+ const parsed = parseDevNotesProjectComment(created);
1016
+ return await jsonResponse(request, options.corsHeaders, {
1017
+ id: parsed?.id || String(created.id || ""),
1018
+ name: String(body.name || "").trim(),
1019
+ is_default: false,
1020
+ created_by: user.id,
1021
+ created_at: parsed?.created_at || (/* @__PURE__ */ new Date()).toISOString()
1022
+ });
1023
+ }
1024
+ if (resource === "report-types" && resourceId && method === "DELETE") {
1025
+ await deleteForgeProjectComment(forgeContext, decodeURIComponent(resourceId));
1026
+ return await jsonResponse(request, options.corsHeaders, { success: true });
1027
+ }
1028
+ if (resource === "task-lists" && method === "GET" && !resourceId) {
1029
+ return await jsonResponse(request, options.corsHeaders, taskLists);
1030
+ }
1031
+ if (resource === "task-lists" && method === "POST" && !resourceId) {
1032
+ const created = await createForgeProjectComment(forgeContext, project.id, "", {
1033
+ kind: "task_list",
1034
+ name: String(body.name || "").trim(),
1035
+ share_slug: generateDevNotesShareSlug(),
1036
+ is_default: false,
1037
+ created_by: user.id,
1038
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1039
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1040
+ });
1041
+ const parsed = parseDevNotesProjectComment(created);
1042
+ return await jsonResponse(request, options.corsHeaders, {
1043
+ id: parsed?.id || String(created.id || ""),
1044
+ name: String(body.name || "").trim(),
1045
+ share_slug: String(parsed?.meta.share_slug || generateDevNotesShareSlug()),
1046
+ is_default: false,
1047
+ created_by: user.id,
1048
+ created_at: parsed?.created_at || (/* @__PURE__ */ new Date()).toISOString(),
1049
+ updated_at: parsed?.updated_at || (/* @__PURE__ */ new Date()).toISOString()
1050
+ });
1051
+ }
1052
+ if (resource === "messages" && resourceId === "read" && method === "POST") {
1053
+ const messageIds = Array.isArray(body.messageIds) ? body.messageIds.map((value) => String(value || "").trim()).filter((value) => Boolean(value)) : [];
1054
+ for (const messageId of Array.from(new Set(messageIds))) {
1055
+ if (readMarkers.has(messageId)) continue;
1056
+ await createForgeProjectComment(forgeContext, project.id, "", {
1057
+ kind: "message_read",
1058
+ messageId,
1059
+ userId: user.id,
1060
+ readAt: (/* @__PURE__ */ new Date()).toISOString()
1061
+ });
1062
+ }
1063
+ return await jsonResponse(request, options.corsHeaders, { success: true });
1064
+ }
1065
+ if (resource === "messages" && resourceId && method === "PATCH") {
1066
+ const current = await fetchForgeCommentById(forgeContext, decodeURIComponent(resourceId));
1067
+ if (!current) {
1068
+ return await jsonResponse(
1069
+ request,
1070
+ options.corsHeaders,
1071
+ { error: "Message not found" },
1072
+ 404
1073
+ );
1074
+ }
1075
+ const parsed = parseDevNotesProjectComment(current);
1076
+ if (!parsed || parsed.meta.kind !== "message") {
1077
+ return await jsonResponse(
1078
+ request,
1079
+ options.corsHeaders,
1080
+ { error: "Message not found" },
1081
+ 404
1082
+ );
1083
+ }
1084
+ const updated = await updateForgeProjectComment(
1085
+ forgeContext,
1086
+ decodeURIComponent(resourceId),
1087
+ String(body.body || "").trim(),
1088
+ parsed.meta
1089
+ );
1090
+ const updatedParsed = parseDevNotesProjectComment(updated);
1091
+ return await jsonResponse(request, options.corsHeaders, {
1092
+ id: updatedParsed?.id || decodeURIComponent(resourceId),
1093
+ bug_report_id: String(parsed.meta.reportId || ""),
1094
+ author_id: String(parsed.meta.authorId || parsed.user_id || ""),
1095
+ body: updatedParsed?.body || String(body.body || "").trim(),
1096
+ created_at: updatedParsed?.created_at || parsed.created_at,
1097
+ updated_at: updatedParsed?.updated_at || (/* @__PURE__ */ new Date()).toISOString(),
1098
+ author: {
1099
+ id: String(parsed.meta.authorId || parsed.user_id || ""),
1100
+ email: parsed.meta.authorEmail === null || parsed.meta.authorEmail === void 0 ? null : String(parsed.meta.authorEmail),
1101
+ full_name: parsed.meta.authorName === null || parsed.meta.authorName === void 0 ? null : String(parsed.meta.authorName)
1102
+ }
1103
+ });
1104
+ }
1105
+ if (resource === "messages" && resourceId && method === "DELETE") {
1106
+ await deleteForgeProjectComment(forgeContext, decodeURIComponent(resourceId));
1107
+ return await jsonResponse(request, options.corsHeaders, { success: true });
1108
+ }
1109
+ if (resource === "unread-counts" && method === "GET") {
1110
+ const counts = {};
1111
+ metadataComments.filter((comment) => comment.meta.kind === "message").forEach((comment) => {
1112
+ const reportId = String(comment.meta.reportId || "").trim();
1113
+ const authorId = String(comment.meta.authorId || "").trim();
1114
+ if (!reportId || authorId === user.id || readMarkers.has(comment.id)) return;
1115
+ counts[reportId] = (counts[reportId] || 0) + 1;
1116
+ });
1117
+ return await jsonResponse(request, options.corsHeaders, counts);
1118
+ }
1119
+ if (resource === "collaborators" && method === "GET") {
1120
+ const tasks = await fetchForgeTasksForProject(forgeContext, project.id);
1121
+ const reports = tasks.map(
1122
+ (task) => buildDevNotesReportFromForgeTask(
1123
+ task,
1124
+ reportPatchById.get(String(task.id || "").trim()) || null,
1125
+ defaultTaskListId
1126
+ )
1127
+ ).filter((item) => Boolean(item));
1128
+ const knownUsers = buildKnownUsers(metadataComments, reports, user);
1129
+ const ids = (url.searchParams.get("ids") || "").split(",").map((value) => value.trim()).filter(Boolean);
1130
+ const resolvedUsers = await maybeResolveUsers(options.resolveUsers, ids);
1131
+ resolvedUsers.forEach((resolved) => knownUsers.set(resolved.id, resolved));
1132
+ const collaborators = ids.length > 0 ? ids.map((id) => knownUsers.get(id)).filter((value) => Boolean(value)) : Array.from(knownUsers.values());
1133
+ return await jsonResponse(
1134
+ request,
1135
+ options.corsHeaders,
1136
+ sortCreators(collaborators)
1137
+ );
1138
+ }
1139
+ return await jsonResponse(request, options.corsHeaders, { error: "Not found" }, 404);
1140
+ } catch (error) {
1141
+ if (error instanceof UpstreamForgeError) {
1142
+ return await passthroughUpstreamResponse(request, options.corsHeaders, error);
1143
+ }
1144
+ return await jsonResponse(
1145
+ request,
1146
+ options.corsHeaders,
1147
+ { error: error instanceof Error ? error.message : "Unexpected error" },
1148
+ 500
1149
+ );
1150
+ }
1151
+ };
1152
+ }
1153
+
1
1154
  // src/server/router.ts
1155
+ function isDevNotesProxyBackend(value) {
1156
+ const candidate = value;
1157
+ return Boolean(
1158
+ candidate && typeof candidate === "object" && typeof candidate.getCapabilities === "function" && typeof candidate.getAppLinkStatus === "function" && typeof candidate.listReports === "function"
1159
+ );
1160
+ }
2
1161
  var json = (body, status = 200) => ({ status, body });
3
1162
  var getId = (slug, index) => decodeURIComponent(slug[index] || "");
4
1163
  async function routeDevNotesProxy(backend, req) {
@@ -88,7 +1247,7 @@ async function readBody(request) {
88
1247
  return null;
89
1248
  }
90
1249
  }
91
- function createNextDevNotesProxy(backend) {
1250
+ function createLegacyNextProxy(backend) {
92
1251
  return async function handler(request, context = {}) {
93
1252
  const url = new URL(request.url);
94
1253
  const proxyRequest = {
@@ -106,6 +1265,16 @@ function createNextDevNotesProxy(backend) {
106
1265
  });
107
1266
  };
108
1267
  }
1268
+ function createNextDevNotesHandler(options) {
1269
+ return createDevNotesServerHandler(options);
1270
+ }
1271
+ function createNextDevNotesProxy(backendOrOptions) {
1272
+ if (isDevNotesProxyBackend(backendOrOptions)) {
1273
+ return createLegacyNextProxy(backendOrOptions);
1274
+ }
1275
+ return createNextDevNotesHandler(backendOrOptions);
1276
+ }
109
1277
  export {
1278
+ createNextDevNotesHandler,
110
1279
  createNextDevNotesProxy
111
1280
  };