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