@coderook/cli 0.31.4 → 0.31.6

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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.31.4",
5
+ "version": "0.31.6",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -10,6 +10,9 @@ exports.projects = projects;
10
10
  exports.findPublicProject = findPublicProject;
11
11
  exports.findProject = findProject;
12
12
  exports.health = health;
13
+ exports.requestReviews = requestReviews;
14
+ exports.openProposal = openProposal;
15
+ exports.proposals = proposals;
13
16
  exports.mergeTracks = mergeTracks;
14
17
  exports.mergeTrack = mergeTrack;
15
18
  exports.resolveMergeConflict = resolveMergeConflict;
@@ -265,6 +268,22 @@ async function health() {
265
268
  });
266
269
  return (await response.json());
267
270
  }
271
+ /** Ask people, by username, to review a merge. */
272
+ async function requestReviews(mergeTrackId, usernames) {
273
+ const body = await call(`/v1/merge-tracks/${mergeTrackId}/review-requests`, { method: "POST", body: { reviewers: usernames } });
274
+ return body.asked;
275
+ }
276
+ /** Offer a line of work to another, usually main. */
277
+ async function openProposal(repositoryId, input) {
278
+ return call(`/v1/repositories/${repositoryId}/proposals`, {
279
+ method: "POST",
280
+ body: input,
281
+ });
282
+ }
283
+ async function proposals(repositoryId, all = false) {
284
+ const body = await call(`/v1/repositories/${repositoryId}/proposals${all ? "?state=all" : ""}`);
285
+ return body.proposals;
286
+ }
268
287
  async function mergeTracks(repositoryId) {
269
288
  const body = await call(`/v1/repositories/${repositoryId}/merge-tracks`);
270
289
  return body.mergeTracks;
@@ -1026,6 +1026,17 @@ The connection failed: ${text}`));
1026
1026
  observedHeadVersionId: result.versionId,
1027
1027
  local: result.local,
1028
1028
  manifest: result.manifest,
1029
+ /*
1030
+ The line this folder is on survives a save onto it.
1031
+
1032
+ The same fault `get` had and lost: this record is built fresh, and
1033
+ without naming `track` every save onto a line moved the folder back to
1034
+ main — so the second save on a line went to main, and `cbx propose`
1035
+ found the folder "on main" straight after `cbx track spike --new` and a
1036
+ save. A one-off `--track` does not move it either: that flag means
1037
+ "just this once", so the folder keeps the line it had.
1038
+ */
1039
+ ...(link?.track ? { track: link.track } : {}),
1029
1040
  });
1030
1041
  /*
1031
1042
  What this save hashed, kept for the next one.
@@ -1547,6 +1558,69 @@ async function commandDoctor() {
1547
1558
  * project looks untouched — so this is the way to find out that work of
1548
1559
  * yours is sitting somewhere, still complete, waiting.
1549
1560
  */
1561
+ /**
1562
+ * Offer this folder's line of work to another line, usually main.
1563
+ *
1564
+ * The folder's line is the one proposed, because it is the one being worked
1565
+ * on; proposing main is refused rather than guessed at, since the answer to
1566
+ * "propose what?" is then a line nobody named.
1567
+ */
1568
+ async function commandPropose(parsed) {
1569
+ const folder = folderFor(parsed);
1570
+ const link = await (0, config_js_1.readLink)(folder);
1571
+ if (!link) {
1572
+ console.error(red("This folder is not linked to a project on your account."));
1573
+ return 1;
1574
+ }
1575
+ const from = flagText(parsed, "from") ?? (await (0, track_commands_js_1.trackFor)(folder));
1576
+ const into = flagText(parsed, "into") ?? "main";
1577
+ if (from === into) {
1578
+ console.error(red(`This folder is on ${from}. Start a line with cbx track <name> --new, save onto it, then propose it.`));
1579
+ return 1;
1580
+ }
1581
+ const title = flagText(parsed, "message", "m", "title");
1582
+ if (!title) {
1583
+ console.error(red('Give it a title: cbx propose -m "What this changes"'));
1584
+ return 1;
1585
+ }
1586
+ const opened = await (0, api_js_1.openProposal)(link.repositoryId, {
1587
+ from,
1588
+ into,
1589
+ title,
1590
+ ...(flagText(parsed, "body") ? { body: flagText(parsed, "body") } : {}),
1591
+ });
1592
+ const reference = opened.mergeTrack.reference;
1593
+ console.log(`${accent(reference)} ${from} → ${into} ${title}`);
1594
+ if (opened.conflicts.settled) {
1595
+ console.log(dim(` ${opened.conflicts.settled} file(s) both sides changed were combined line by line.`));
1596
+ }
1597
+ console.log(opened.conflicts.unresolved
1598
+ ? ` ${opened.conflicts.unresolved} file(s) need a decision. Run cbx merge ${reference} to look.`
1599
+ : dim(` Nothing to decide. Run cbx merge ${reference} --apply when it has been reviewed.`));
1600
+ return 0;
1601
+ }
1602
+ async function commandProposals(parsed) {
1603
+ const folder = folderFor(parsed);
1604
+ const link = await (0, config_js_1.readLink)(folder);
1605
+ if (!link) {
1606
+ console.error(red("This folder is not linked to a project on your account."));
1607
+ return 1;
1608
+ }
1609
+ const listed = await (0, api_js_1.proposals)(link.repositoryId, hasFlag(parsed, "all"));
1610
+ if (!listed.length) {
1611
+ console.log(hasFlag(parsed, "all") ? "No proposals yet." : "No open proposals.");
1612
+ return 0;
1613
+ }
1614
+ for (const proposal of listed) {
1615
+ const counts = proposal.conflicts;
1616
+ const waiting = counts && counts.unresolved ? red(`${counts.unresolved} to decide`) : "";
1617
+ console.log(`${accent(proposal.reference)} ${proposal.proposal?.from ?? "?"} → ${proposal.proposal?.into ?? "?"} ` +
1618
+ `${proposal.proposal?.title ?? ""} ${waiting}` +
1619
+ (proposal.state === "open" ? "" : dim(` ${proposal.state}`)));
1620
+ }
1621
+ console.log(dim("\nRun cbx merge <reference> to look at one."));
1622
+ return 0;
1623
+ }
1550
1624
  async function commandMerges(parsed) {
1551
1625
  const folder = folderFor(parsed);
1552
1626
  const link = await (0, config_js_1.readLink)(folder);
@@ -1593,6 +1667,17 @@ async function commandMerge(parsed) {
1593
1667
  const folder = folderFor({ ...parsed, positional: parsed.positional.slice(1) });
1594
1668
  const summary = await findMerge(folder, reference);
1595
1669
  const detail = await (0, api_js_1.mergeTrack)(summary.id);
1670
+ /*
1671
+ Asking people to look. Named the way people name each other, and said
1672
+ back by name, so a typo in a username is visible rather than silent.
1673
+ */
1674
+ const ask = flagText(parsed, "ask");
1675
+ if (ask) {
1676
+ const asked = await (0, api_js_1.requestReviews)(summary.id, ask.split(",").map((name) => name.trim()).filter(Boolean));
1677
+ console.log(asked.length
1678
+ ? `Asked ${asked.map((person) => person.displayName).join(", ")} to review ${summary.reference}.`
1679
+ : "Nobody new was asked.");
1680
+ }
1596
1681
  if (hasFlag(parsed, "cancel")) {
1597
1682
  await (0, api_js_1.cancelMerge)(summary.id);
1598
1683
  console.log(`${summary.reference} cancelled. Your upload is still stored and nothing published was touched.`);
@@ -1629,6 +1714,32 @@ async function commandMerge(parsed) {
1629
1714
  }
1630
1715
  }
1631
1716
  const now = await (0, api_js_1.mergeTrack)(summary.id);
1717
+ if (now.proposal) {
1718
+ console.log(`\n${accent(now.mergeTrack.reference)} ${now.proposal.from} → ${now.proposal.into}` +
1719
+ (now.changeRequest ? ` ${now.changeRequest.title}` : ""));
1720
+ const approvals = now.changeRequest?.approvals;
1721
+ if (approvals && approvals.required) {
1722
+ console.log(` ${approvals.satisfied ? accent("approved") : red("waiting for review")}` +
1723
+ dim(` · ${approvals.approvals} of ${approvals.required} approvals`));
1724
+ }
1725
+ for (const blocked of now.provisional.blockedByChecks ?? []) {
1726
+ console.log(` ${red("check")} ${blocked}`);
1727
+ }
1728
+ for (const one of now.changeRequest?.reviews ?? []) {
1729
+ if (one.verdict === "comment" && !one.body)
1730
+ continue;
1731
+ const said = one.verdict === "approve"
1732
+ ? accent("approved")
1733
+ : one.verdict === "request_changes"
1734
+ ? red("asked for changes")
1735
+ : dim("commented");
1736
+ console.log(` ${one.reviewerName ?? "A reviewer"} ${said}${one.body ? dim(`: ${one.body}`) : ""}`);
1737
+ }
1738
+ const waiting = (now.changeRequest?.requested ?? []).filter((person) => !person.reviewed);
1739
+ if (waiting.length) {
1740
+ console.log(dim(` waiting on ${waiting.map((person) => person.displayName).join(", ")}`));
1741
+ }
1742
+ }
1632
1743
  console.log(`
1633
1744
  ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1634
1745
  (now.provisional.ready
@@ -1646,10 +1757,14 @@ ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1646
1757
  const applied = await (0, api_js_1.applyMerge)(summary.id);
1647
1758
  console.log(`
1648
1759
  Applied as ${accent(`v${applied.version.sequence}`)}.`);
1760
+ if (applied.closedIssues?.length) {
1761
+ console.log(`Closed ${applied.closedIssues.map((number) => `#${number}`).join(", ")}.`);
1762
+ }
1649
1763
  console.log(dim("Run cbx get to bring it down to this folder."));
1650
1764
  return 0;
1651
1765
  }
1652
- if (!decision) {
1766
+ const stillOpen = now.conflicts.some((conflict) => !conflict.resolvedAt);
1767
+ if (!decision && stillOpen) {
1653
1768
  console.log(dim(`
1654
1769
  --mine keeps yours, --theirs keeps what was already saved,` +
1655
1770
  ` --drop removes the file.
@@ -2197,7 +2312,7 @@ const SPECS = [
2197
2312
  detail: "A signed POST to an address of yours when something happens here.\n" +
2198
2313
  "The signing secret is generated by the service and shown once, when\n" +
2199
2314
  "the hook is added — no route hands it back afterwards.\n\n" +
2200
- "Events: version.published, merge.opened, merge.applied,\n" +
2315
+ "Events: version.published, merge.opened, merge.applied, merge.cancelled,\n" +
2201
2316
  "check.reported, change_request.opened, change_request.reviewed.\n" +
2202
2317
  "Naming none of them sends all of them.",
2203
2318
  options: [
@@ -2466,12 +2581,16 @@ const SPECS = [
2466
2581
  options: [
2467
2582
  { flags: "--ship <action>", description: "its builds become downloads on the version" },
2468
2583
  { flags: "--no-ship <action>", description: "keep its files without attaching them" },
2584
+ { flags: "--on-save <action>", description: "run it on every save" },
2585
+ { flags: "--on-proposal <action>", description: "run it on proposals; its result becomes a check" },
2586
+ { flags: "--by-hand <action>", description: "run it only when somebody starts it" },
2469
2587
  { flags: "--project <name>", description: "which project" },
2470
2588
  ],
2471
2589
  examples: [
2472
2590
  "cbx actions",
2473
2591
  'cbx actions --ship "Windows installer"',
2474
2592
  'cbx actions --no-ship "Windows installer"',
2593
+ 'cbx actions --on-proposal Tests',
2475
2594
  ],
2476
2595
  run: service_commands_js_1.commandWorkflows,
2477
2596
  },
@@ -2552,6 +2671,31 @@ const SPECS = [
2552
2671
  options: [{ flags: "--revoke <id>", description: "stop one working now" }],
2553
2672
  run: service_commands_js_1.commandTokens,
2554
2673
  },
2674
+ {
2675
+ name: "propose",
2676
+ group: "Working with a folder",
2677
+ summary: "offer this folder's line to main",
2678
+ usage: 'propose -m "title" [--into main] [--body text] [folder]',
2679
+ options: [
2680
+ { flags: "-m, --message <text>", description: "the proposal's title" },
2681
+ { flags: "--into <line>", description: "the line to propose into (default main)" },
2682
+ { flags: "--from <line>", description: "propose this line instead of the folder's" },
2683
+ { flags: "--body <text>", description: "a longer description" },
2684
+ ],
2685
+ detail: "Opens a proposal from the line this folder is on into main (or --into).\n" +
2686
+ "Files both sides changed are combined line by line where they can be;\n" +
2687
+ "the rest wait for a decision in cbx merge. \"Fixes #12\" in the title or\n" +
2688
+ "body closes issue 12 when the proposal lands.",
2689
+ run: commandPropose,
2690
+ },
2691
+ {
2692
+ name: "proposals",
2693
+ group: "Working with a folder",
2694
+ summary: "open proposals on this project",
2695
+ usage: "proposals [--all] [folder]",
2696
+ options: [{ flags: "--all", description: "include merged and cancelled ones" }],
2697
+ run: commandProposals,
2698
+ },
2555
2699
  {
2556
2700
  name: "merges",
2557
2701
  group: "When somebody saved first",
@@ -2571,6 +2715,7 @@ const SPECS = [
2571
2715
  { flags: "--theirs", description: "keep theirs" },
2572
2716
  { flags: "--both", description: "keep both" },
2573
2717
  { flags: "--drop", description: "abandon the upload" },
2718
+ { flags: "--ask <usernames>", description: "ask people to review it, comma separated" },
2574
2719
  ],
2575
2720
  run: commandMerge,
2576
2721
  },
@@ -314,6 +314,46 @@ async function commandWorkflows(parsed) {
314
314
  }
315
315
  console.log("");
316
316
  }
317
+ /*
318
+ When an action runs. A trigger used to be a label nothing acted on; now a
319
+ save or a proposal really does start the actions that asked for it, so
320
+ choosing is worth a flag.
321
+ */
322
+ const WHEN = [
323
+ ["on-save", "version_saved", "runs on every save"],
324
+ ["on-proposal", "merge_opened", "runs when a line is proposed, and again on each save to it"],
325
+ ["by-hand", "manual", "runs when somebody starts it"],
326
+ ];
327
+ for (const [flag, trigger, meaning] of WHEN) {
328
+ const named = parsed.flags.get(flag);
329
+ if (named === undefined)
330
+ continue;
331
+ if (typeof named !== "string" || !named.trim()) {
332
+ console.error(red("Which action?") + dim(` cbx actions --${flag} "Tests"`));
333
+ return 1;
334
+ }
335
+ const target = found.find((workflow) => workflow.name.toLowerCase() === named.trim().toLowerCase());
336
+ if (!target) {
337
+ console.error(red(`${project.name} has no action called ${named.trim()}.`));
338
+ if (found.length) {
339
+ console.error(dim(` It has: ${found.map((one) => one.name).join(", ")}`));
340
+ }
341
+ return 1;
342
+ }
343
+ try {
344
+ await (0, api_js_1.changeWorkflow)(project.id, target.id, { trigger });
345
+ }
346
+ catch (error) {
347
+ console.error(red(error instanceof Error ? error.message : String(error)));
348
+ return 1;
349
+ }
350
+ target.trigger = trigger;
351
+ console.log(green(`${target.name} ${meaning}.`));
352
+ if (trigger === "merge_opened") {
353
+ console.log(dim(" Its result becomes a required check on the proposal."));
354
+ }
355
+ console.log("");
356
+ }
317
357
  if (!found.length) {
318
358
  console.log(dim("No actions on this project."));
319
359
  return 0;
@@ -256,8 +256,36 @@ class Tracks {
256
256
  ready: body.provisional?.ready ?? false,
257
257
  unresolvedPaths: body.provisional?.unresolvedPaths ?? [],
258
258
  blockedByChecks: body.provisional?.blockedByChecks ?? [],
259
+ proposal: body.proposal ?? null,
260
+ request: body.changeRequest
261
+ ? { ...body.changeRequest, requested: body.changeRequest.requested ?? [] }
262
+ : null,
259
263
  };
260
264
  }
265
+ /**
266
+ * Offer a line to another, usually main.
267
+ *
268
+ * The service reads both lines, combines what it can, and answers with the
269
+ * merge it opened — which the window then shows like any other.
270
+ */
271
+ async propose(repositoryId, input) {
272
+ const body = await this.call(`/v1/repositories/${repositoryId}/proposals`, {
273
+ method: "POST",
274
+ body: JSON.stringify(input.body ? input : { from: input.from, into: input.into, title: input.title }),
275
+ });
276
+ return {
277
+ mergeTrackId: body.mergeTrack.id,
278
+ reference: body.mergeTrack.reference,
279
+ unresolved: body.conflicts.unresolved,
280
+ };
281
+ }
282
+ /** Say where you stand on a proposal. */
283
+ async review(mergeTrackId, verdict, note) {
284
+ await this.call(`/v1/merge-tracks/${mergeTrackId}/review`, {
285
+ method: "PUT",
286
+ body: JSON.stringify(note ? { verdict, body: note } : { verdict }),
287
+ });
288
+ }
261
289
  /**
262
290
  * One side of a conflict, as text.
263
291
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.31.4",
3
+ "version": "0.31.6",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",