@unbrained/pm-web 2026.9.23 → 2026.10.5

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.
package/dist/routes/pm.js CHANGED
@@ -18,6 +18,16 @@ import { pool } from "../db.js";
18
18
  // Singleton Neo4j driver — reused across sync calls to avoid per-call connection overhead.
19
19
  let _neo4jDriver = null;
20
20
  let _neo4jDriverKey = "";
21
+ /** Require complete graph credentials before provisioning extensions or opening a driver. */
22
+ function requireNeo4jCredentials() {
23
+ const uri = process.env.NEO4J_URI;
24
+ const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME;
25
+ const password = process.env.NEO4J_PASSWORD;
26
+ if (!uri || !user || !password) {
27
+ throw new Error("Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD before syncing the graph.");
28
+ }
29
+ return { uri, user, password };
30
+ }
21
31
  /**
22
32
  * Return the process-wide Neo4j driver, recreating it when the connection key changes.
23
33
  *
@@ -34,9 +44,7 @@ let _neo4jDriverKey = "";
34
44
  * credential cannot leak through a value held for the lifetime of the process.
35
45
  */
36
46
  function getNeo4jDriver() {
37
- const uri = process.env.NEO4J_URI ?? "";
38
- const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME ?? "";
39
- const password = process.env.NEO4J_PASSWORD ?? "";
47
+ const { uri, user, password } = requireNeo4jCredentials();
40
48
  const key = `${uri}:${user}:${createHash("sha256").update(password).digest("hex")}`;
41
49
  if (!_neo4jDriver || _neo4jDriverKey !== key) {
42
50
  if (_neo4jDriver) {
@@ -224,13 +232,19 @@ function normalizeDependencyKind(input) {
224
232
  */
225
233
  function graphFromItems(items, depsByItem) {
226
234
  const nodesById = new Map();
235
+ const itemIds = new Set(items.map((item) => item.id));
227
236
  const relationships = [];
237
+ const relationshipKeys = new Set();
228
238
  const addNode = (node) => {
229
239
  if (!nodesById.has(node.id))
230
240
  nodesById.set(node.id, node);
231
241
  };
232
242
  const addRelationship = (from, to, type, properties) => {
233
- if (!nodesById.has(to) && !items.some((item) => item.id === to)) {
243
+ const key = JSON.stringify([from, to, type]);
244
+ if (relationshipKeys.has(key))
245
+ return;
246
+ relationshipKeys.add(key);
247
+ if (!nodesById.has(to) && !itemIds.has(to)) {
234
248
  addNode({
235
249
  id: to,
236
250
  labels: ["ExternalPmItem"],
@@ -273,16 +287,11 @@ function graphFromItems(items, depsByItem) {
273
287
  ...(item.dependencies ?? []),
274
288
  ...(depsByItem.get(item.id) ?? []),
275
289
  ];
276
- const seenDeps = new Set();
277
290
  for (const dep of deps) {
278
291
  const target = dependencyTarget(dep);
279
292
  if (!target)
280
293
  continue;
281
294
  const type = graphRelationshipType(dep.type ?? dep.kind ?? dep.relation ?? dep.rel ?? dep.relationship);
282
- const key = `${item.id}->${target}:${type}`;
283
- if (seenDeps.has(key))
284
- continue;
285
- seenDeps.add(key);
286
295
  addRelationship(item.id, target, type, { ...dep });
287
296
  }
288
297
  const facetLinks = [
@@ -319,7 +328,7 @@ function graphFromItems(items, depsByItem) {
319
328
  generatedAt: new Date().toISOString(),
320
329
  source: "pm-web",
321
330
  nodes: Array.from(nodesById.values()),
322
- relationships: relationships.filter((rel, index, all) => all.findIndex((candidate) => candidate.from === rel.from && candidate.to === rel.to && candidate.type === rel.type) === index),
331
+ relationships,
323
332
  };
324
333
  }
325
334
  function graphProjectKey(project) {
@@ -338,12 +347,6 @@ function graphProjectKey(project) {
338
347
  * @returns The number of nodes and relationships written from the graph.
339
348
  */
340
349
  async function syncGraphToNeo4j(graph, projectKey) {
341
- const uri = process.env.NEO4J_URI;
342
- const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME;
343
- const password = process.env.NEO4J_PASSWORD;
344
- if (!uri || !user || !password) {
345
- throw new Error("Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD before syncing the graph.");
346
- }
347
350
  const driver = getNeo4jDriver();
348
351
  const session = driver.session({ database: process.env.NEO4J_DATABASE });
349
352
  try {
@@ -361,7 +364,9 @@ async function syncGraphToNeo4j(graph, projectKey) {
361
364
  }
362
365
  return { syncedNodes: graph.nodes.length, syncedRelationships: graph.relationships.length };
363
366
  }
367
+ /** Validate graph configuration before assembling a project graph or provisioning its extension. */
364
368
  async function syncProjectGraph(project) {
369
+ requireNeo4jCredentials();
365
370
  const extensionGraph = await pmGraphExtensionGraphForProject(project);
366
371
  const graph = extensionGraph.graph ?? await fallbackGraphForProject(project.ownerUserId, project.slug);
367
372
  return syncGraphToNeo4j(graph, graphProjectKey(project));
@@ -446,7 +451,7 @@ function itemsFromCompleteList(parsed) {
446
451
  * @returns A pm-web-sourced project graph.
447
452
  */
448
453
  async function fallbackGraphForProject(ownerUserId, slug) {
449
- const itemsResult = await readCompletePmItems(ownerUserId, slug);
454
+ const itemsResult = await readCompletePmItems(ownerUserId, slug, false, true);
450
455
  if (!itemsResult.ok)
451
456
  throw new Error(itemsResult.stderr || "Failed to load items for graph");
452
457
  const items = itemsFromCompleteList(itemsResult.result);
@@ -457,8 +462,8 @@ async function fallbackGraphForProject(ownerUserId, slug) {
457
462
  /**
458
463
  * Fetch a project graph from the `pm-graph` extension, when installed.
459
464
  *
460
- * Ensures the extension is provisioned for the project (returning `{ error }`
461
- * if that fails), runs `pm-graph export --json`, and parses its output. Returns
465
+ * Used only by edit-protected graph sync. Provisions the extension if needed,
466
+ * then runs `pm-graph export --json` and parses its output. Returns
462
467
  * `{ graph }` when the extension produced valid JSON with a graph, otherwise an
463
468
  * `{ error }` so the caller can fall back to a pm-web-built graph.
464
469
  *
@@ -467,9 +472,8 @@ async function fallbackGraphForProject(ownerUserId, slug) {
467
472
  */
468
473
  async function pmGraphExtensionGraphForProject(project) {
469
474
  const provision = await ensureGraphExtension(project.ownerUserId, project.slug);
470
- if (!provision.ok) {
475
+ if (!provision.ok)
471
476
  return { error: provision.error };
472
- }
473
477
  const extensionResult = await projectPm(project, ["pm-graph", "export", "--json"], false);
474
478
  let extensionData;
475
479
  if (extensionResult.ok && extensionResult.stdout) {
@@ -845,42 +849,24 @@ router.post("/items/:itemId/history-repair", async (req, res) => {
845
849
  }
846
850
  res.json(result.parsed || { ok: true, id: itemId });
847
851
  });
848
- // GET /api/projects/:projectId/pm/list
849
- router.get("/list", async (req, res) => {
850
- const project = await requireProject(req, res);
851
- if (!project)
852
- return;
853
- const { status, type, limit, priority, sprint, release, assignee, after } = req.query;
854
- const result = await runCursorList(res, project, ["list"], [
855
- ["--status", status],
856
- ["--type", type],
857
- ["--limit", limit],
858
- ["--priority", priority],
859
- ["--sprint", sprint],
860
- ["--release", release],
861
- ["--assignee", assignee],
862
- ], after);
863
- if (!result)
864
- return;
865
- res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
866
- });
867
- // GET /api/projects/:projectId/pm/list-all
868
- router.get("/list-all", async (req, res) => {
869
- const project = await requireProject(req, res);
870
- if (!project)
871
- return;
872
- const { type, limit, after } = req.query;
873
- // Preserve the public HTTP compatibility route while invoking the canonical
874
- // CLI/SDK command internally. This route is intentionally paginated and is
875
- // therefore distinct from readCompletePmItems used by whole-corpus views.
876
- const result = await runCursorList(res, project, ["list", "--all"], [
877
- ["--type", type],
878
- ["--limit", limit],
879
- ], after);
880
- if (!result)
881
- return;
882
- res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
883
- });
852
+ // Keep the public /list-all compatibility route paginated while invoking the
853
+ // canonical CLI/SDK list --all command. Whole-corpus views use readCompletePmItems.
854
+ for (const route of [
855
+ { path: "/list", args: ["list"], filters: ["status", "type", "limit", "priority", "sprint", "release", "assignee"] },
856
+ { path: "/list-all", args: ["list", "--all"], filters: ["type", "limit"] },
857
+ ]) {
858
+ router.get(route.path, async (req, res) => {
859
+ const project = await requireProject(req, res);
860
+ if (!project)
861
+ return;
862
+ const query = req.query;
863
+ const flags = route.filters.map((key) => [`--${key}`, query[key]]);
864
+ const result = await runCursorList(res, project, route.args, flags, query.after);
865
+ if (!result)
866
+ return;
867
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
868
+ });
869
+ }
884
870
  // GET /api/projects/:projectId/pm/board
885
871
  // Kanban board: items grouped into columns by the workspace's runtime statuses
886
872
  // (read live from `pm contracts`) so the board matches the installed CLI.
@@ -1382,21 +1368,11 @@ router.get("/graph", async (req, res) => {
1382
1368
  const project = await requireProject(req, res);
1383
1369
  if (!project)
1384
1370
  return;
1385
- const extensionGraph = await pmGraphExtensionGraphForProject(project);
1386
- if (extensionGraph.graph) {
1387
- res.json({
1388
- ok: true,
1389
- graph: extensionGraph.graph,
1390
- extensionAvailable: true,
1391
- });
1392
- return;
1393
- }
1394
1371
  try {
1395
1372
  res.json({
1396
1373
  ok: true,
1397
1374
  graph: await fallbackGraphForProject(project.ownerUserId, project.slug),
1398
1375
  extensionAvailable: false,
1399
- extensionError: extensionGraph.error,
1400
1376
  });
1401
1377
  }
1402
1378
  catch (err) {
@@ -1445,18 +1421,32 @@ router.get("/graph/neighbors/:nodeId", async (req, res) => {
1445
1421
  res.status(400).json({ error: "nodeId is required" });
1446
1422
  return;
1447
1423
  }
1448
- const result = await projectPm(project, ["pm-graph", "neighbors", nodeId, "--json"], false);
1449
- if (!result.ok) {
1450
- // Extension not available — return empty neighbors
1451
- res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, error: result.stderr || "pm-graph extension not available" });
1452
- return;
1453
- }
1454
1424
  try {
1455
- const parsed = result.stdout ? JSON.parse(result.stdout) : null;
1456
- res.json({ ok: true, ...parsed, extensionAvailable: true });
1425
+ const graph = await fallbackGraphForProject(project.ownerUserId, project.slug);
1426
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
1427
+ const center = nodesById.get(nodeId);
1428
+ if (!center) {
1429
+ res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, message: `No node found with id "${nodeId}".` });
1430
+ return;
1431
+ }
1432
+ const neighbors = graph.relationships
1433
+ .filter((edge) => edge.from === nodeId || edge.to === nodeId)
1434
+ .map((edge) => {
1435
+ // graphFromItems creates both endpoint nodes for every relationship.
1436
+ const node = nodesById.get(edge.from === nodeId ? edge.to : edge.from);
1437
+ return {
1438
+ node: { ...node.properties, _labels: node.labels },
1439
+ relationship: {
1440
+ type: edge.type,
1441
+ direction: edge.from === nodeId ? "outgoing" : "incoming",
1442
+ properties: { ...edge.properties, _type: edge.type },
1443
+ },
1444
+ };
1445
+ });
1446
+ res.json({ ok: true, center: { ...center.properties, _labels: center.labels }, neighbors, extensionAvailable: false });
1457
1447
  }
1458
- catch {
1459
- res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, error: "pm-graph neighbors returned invalid JSON" });
1448
+ catch (err) {
1449
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
1460
1450
  }
1461
1451
  });
1462
1452
  // POST /api/projects/:projectId/pm/graph/query
@@ -1562,9 +1552,13 @@ router.post("/tests/:itemId", async (req, res) => {
1562
1552
  res.status(400).json({ error: "Test command is required" });
1563
1553
  return;
1564
1554
  }
1565
- const args = ["test", routeParam(req, "itemId"), "--add", "--command", command.trim()];
1566
- if (description)
1567
- args.push("--description", description.trim());
1555
+ // `pm item test --add` takes one CSV/JSON argument; the historical
1556
+ // `--add --command <value>` token shape is rejected by the CLI, so every
1557
+ // add from the UI failed with a usage error. JSON keeps the user's command
1558
+ // verbatim even when it contains commas or equals signs, and the optional
1559
+ // description rides along as the `note` key.
1560
+ const test = description?.trim() ? { command: command.trim(), note: description.trim() } : { command: command.trim() };
1561
+ const args = ["test", routeParam(req, "itemId"), "--add-json", JSON.stringify(test)];
1568
1562
  const result = await runMutation(res, project, args, "Failed to add test");
1569
1563
  if (!result)
1570
1564
  return;
@@ -1922,10 +1916,23 @@ router.post("/close-many", async (req, res) => {
1922
1916
  return;
1923
1917
  }
1924
1918
  const targetStatus = body.targetStatus === "canceled" ? "canceled" : "closed";
1925
- // First, use update-many --dry-run to get the list of matched items
1926
- const listArgs = ["update-many", "--dry-run", "--status", "open"];
1919
+ // First, use update-many --dry-run to get the list of matched items.
1920
+ // --filter-status selects rows; --status would instead *set* every matched
1921
+ // item's status in the preview, and with no other update flags present the
1922
+ // match set silently widened to the whole project — closed items included.
1923
+ // The status filter is authoritative and passed exactly once: the caller may
1924
+ // narrow to another non-terminal status, but `pm` keeps only the last repeated
1925
+ // flag, so appending a second --filter-status could re-select terminal items.
1926
+ const statusFilter = body.filterStatus?.trim() || "open";
1927
+ // `pm` accepts comma-separated status lists and matches them case- and
1928
+ // whitespace-insensitively, so every token is normalised before the check.
1929
+ if (statusFilter.split(",").some((status) => ["closed", "canceled", "cancelled"].includes(status.trim().toLowerCase()))) {
1930
+ res.status(400).json({ error: "close-many only selects non-terminal items; filterStatus cannot be closed or canceled" });
1931
+ return;
1932
+ }
1933
+ const listArgs = ["update-many", "--dry-run", "--filter-status", statusFilter];
1927
1934
  const filterFlags = {
1928
- filterStatus: "--filter-status", filterType: "--filter-type",
1935
+ filterType: "--filter-type",
1929
1936
  filterTag: "--filter-tag", filterPriority: "--filter-priority",
1930
1937
  filterAssignee: "--filter-assignee", filterParent: "--filter-parent",
1931
1938
  filterSprint: "--filter-sprint", filterRelease: "--filter-release",
@@ -2236,9 +2243,13 @@ router.post("/plan/:planId/steps", async (req, res) => {
2236
2243
  res.status(400).json({ error: "Title is required" });
2237
2244
  return;
2238
2245
  }
2239
- const args = ["plan", "add-step", routeParam(req, "planId"), "--title", title.trim()];
2246
+ // `pm plan add-step` takes the step title on --step-title and the step body
2247
+ // on --step-body; --title/--description address the plan itself, so routing
2248
+ // the request's fields there would fail the whole call (add-step rejects
2249
+ // --title as a missing --step-title) or write to the wrong record.
2250
+ const args = ["plan", "add-step", routeParam(req, "planId"), "--step-title", title.trim()];
2240
2251
  if (description)
2241
- args.push("--description", description);
2252
+ args.push("--step-body", description);
2242
2253
  if (dependsOn)
2243
2254
  args.push("--depends-on", dependsOn);
2244
2255
  await runPlanMutation(req, res, project, args, "Failed to add step", 201);
@@ -2248,8 +2259,16 @@ router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2248
2259
  const project = await requireProject(req, res);
2249
2260
  if (!project)
2250
2261
  return;
2262
+ // Step edits go to --step-title/--step-body. The plan-level --title/--
2263
+ // --description flags are silently ignored by `pm plan update-step`, so
2264
+ // routing a step edit through them returned success while dropping the
2265
+ // user's change on the floor.
2266
+ const body = req.body;
2251
2267
  const args = ["plan", "update-step", routeParam(req, "planId"), routeParam(req, "stepRef")];
2252
- pushTitleDescArgs(args, req.body);
2268
+ if (body.title?.trim())
2269
+ args.push("--step-title", body.title.trim());
2270
+ if (body.description !== undefined)
2271
+ args.push("--step-body", body.description);
2253
2272
  await runPlanMutation(req, res, project, args, "Failed to update step");
2254
2273
  });
2255
2274
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/complete
@@ -2321,39 +2340,37 @@ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2321
2340
  }
2322
2341
  await runPlanMutation(req, res, project, ["plan", "reorder-step", routeParam(req, "planId"), routeParam(req, "stepRef"), String(reorderTo)], "Failed to reorder step");
2323
2342
  });
2324
- // POST /api/projects/:projectId/pm/plan/:planId/link
2325
- router.post("/plan/:planId/link", async (req, res) => {
2343
+ /** Execute plan linking or unlinking with the CLI's required step positional. */
2344
+ async function mutatePlanLink(req, res, action) {
2326
2345
  const project = await requireProject(req, res);
2327
2346
  if (!project)
2328
2347
  return;
2329
- const { link, linkKind, linkNote, promoteToItemDep } = req.body;
2348
+ const { link, step } = req.body;
2330
2349
  if (!link?.trim()) {
2331
2350
  res.status(400).json({ error: "link (item id) is required" });
2332
2351
  return;
2333
2352
  }
2334
- const args = ["plan", "link", routeParam(req, "planId"), "--link", link.trim()];
2335
- if (linkKind)
2336
- args.push("--link-kind", linkKind);
2337
- if (linkNote)
2338
- args.push("--link-note", linkNote);
2339
- if (promoteToItemDep === "true")
2340
- args.push("--promote-to-item-dep");
2341
- await runPlanMutation(req, res, project, args, "Failed to link plan", 201);
2342
- });
2343
- // DELETE /api/projects/:projectId/pm/plan/:planId/link
2344
- router.delete("/plan/:planId/link", async (req, res) => {
2345
- const project = await requireProject(req, res);
2346
- if (!project)
2347
- return;
2348
- const { link, linkKind } = req.body;
2349
- if (!link?.trim()) {
2350
- res.status(400).json({ error: "link (item id) is required" });
2353
+ if (!step?.trim()) {
2354
+ res.status(400).json({ error: "step (step id or order) is required" });
2351
2355
  return;
2352
2356
  }
2353
- const args = ["plan", "unlink", routeParam(req, "planId"), "--link", link.trim()];
2357
+ const { linkKind, linkNote, promoteToItemDep } = req.body;
2358
+ const args = ["plan", action, routeParam(req, "planId"), step.trim(), "--link", link.trim()];
2354
2359
  if (linkKind)
2355
2360
  args.push("--link-kind", linkKind);
2356
- await runPlanMutation(req, res, project, args, "Failed to unlink plan");
2361
+ if (action === "link") {
2362
+ if (linkNote)
2363
+ args.push("--link-note", linkNote);
2364
+ if (promoteToItemDep === "true")
2365
+ args.push("--promote-to-item-dep");
2366
+ }
2367
+ await runPlanMutation(req, res, project, args, `Failed to ${action} plan`, action === "link" ? 201 : 200);
2368
+ }
2369
+ router.post("/plan/:planId/link", async (req, res) => {
2370
+ await mutatePlanLink(req, res, "link");
2371
+ });
2372
+ router.delete("/plan/:planId/link", async (req, res) => {
2373
+ await mutatePlanLink(req, res, "unlink");
2357
2374
  });
2358
2375
  // GET /api/projects/:projectId/pm/upgrade
2359
2376
  // Returns a dry-run preview of what upgrade would do (safe, read-only).