jjhub 0.1.3 → 0.1.4

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.
@@ -298,11 +298,11 @@ var init_api_client = __esm({
298
298
  {}
299
299
  );
300
300
  }
301
- assessLandRisk(id, changeId2) {
301
+ assessLandRisk(id, changeId2, input) {
302
302
  return this.request(
303
303
  "POST",
304
304
  `/repositories/${encodeURIComponent(id)}/changes/${encodeURIComponent(changeId2)}/land-risk`,
305
- {}
305
+ input
306
306
  );
307
307
  }
308
308
  listOperations(id) {
@@ -349,6 +349,16 @@ var init_api_client = __esm({
349
349
  reportCommit(repoId, changeId2, sha) {
350
350
  return this.request("POST", `/repositories/${enc(repoId)}/changes/${enc(changeId2)}/commit`, { sha });
351
351
  }
352
+ /**
353
+ * A short-lived GitHub push credential for real-commit push, minted
354
+ * server-side from this repo's GitHub App installation. Null means
355
+ * there's nothing to mint (no App configured, or not installed on this
356
+ * repo) — the caller (daemon.ts) treats that as a normal graceful-degrade.
357
+ * May not exist on older servers — callers should catch isMissingEndpoint.
358
+ */
359
+ getPushCredential(id) {
360
+ return this.request("POST", `/repositories/${enc(id)}/push-credential`);
361
+ }
352
362
  // --- deleteBookmark: force is a query param, not a JSON body (see operations.ts) ---
353
363
  deleteBookmark(repoId, name, opts) {
354
364
  const qs = opts?.force ? "?force=1" : "";
@@ -59435,9 +59445,12 @@ var init_operations = __esm({
59435
59445
  jjOverlayManaged: external_exports.boolean().optional().describe("Whether JJHub manages jj-native semantics for this repo."),
59436
59446
  stackReviewEnabled: external_exports.boolean().optional().describe("Whether the stack review screen is enabled."),
59437
59447
  defaultBookmark: external_exports.string().min(1).optional().describe("Default bookmark name, e.g. 'main'."),
59438
- contentMode: external_exports.enum(["github", "local"]).optional()
59448
+ contentMode: external_exports.enum(["github", "local"]).optional(),
59449
+ landCheckAppearTimeoutMs: external_exports.number().int().min(0).nullable().optional().describe(
59450
+ "Per-repo override (ms) of how long land waits for a check-run to appear before concluding the repo has no CI. 0 skips the wait entirely. null clears the override (inherits the server-wide default)."
59451
+ )
59439
59452
  }),
59440
- description: "Update a repository's post-creation settings (jjOverlayManaged, stackReviewEnabled, defaultBookmark, contentMode) \u2014 the update dual of createRepository's same-named fields. Omitted fields are left unchanged.",
59453
+ description: "Update a repository's post-creation settings (jjOverlayManaged, stackReviewEnabled, defaultBookmark, contentMode, landCheckAppearTimeoutMs) \u2014 the update dual of createRepository's same-named fields. Omitted fields are left unchanged.",
59441
59454
  returns: "Repository"
59442
59455
  }),
59443
59456
  op({
@@ -59565,9 +59578,12 @@ var init_operations = __esm({
59565
59578
  content: external_exports.string().describe("File content \u2014 UTF-8 text, or base64 if encoding='base64'."),
59566
59579
  encoding: external_exports.enum(["utf-8", "base64"]).optional().describe("Defaults to 'utf-8'.")
59567
59580
  })
59568
- ).describe("Files to write.")
59581
+ ).optional().describe("Files to write. May be omitted/empty for a delete-only commit."),
59582
+ deletePaths: external_exports.array(external_exports.string()).optional().describe(
59583
+ "Repo-relative paths to remove, in the same commit as `files`. A path may not appear in both."
59584
+ )
59569
59585
  }),
59570
- description: "Commit one or more files directly onto a change's projected GitHub branch.",
59586
+ description: "Commit one or more files directly onto a change's projected GitHub branch \u2014 write, delete, or both in one commit.",
59571
59587
  returns: "Change"
59572
59588
  }),
59573
59589
  op({
@@ -59749,7 +59765,10 @@ var init_operations = __esm({
59749
59765
  method: "POST",
59750
59766
  pathParams: ["id", "changeId"],
59751
59767
  path: ([id, changeId2]) => `/repositories/${id}/changes/${changeId2}/land-risk`,
59752
- body: external_exports.object({}),
59768
+ // narrative:false = deterministic-only (skips the AI narrator entirely)
59769
+ // the web land modal uses it so risk renders in ~2 GitHub round trips
59770
+ // instead of waiting up to the AI provider timeout.
59771
+ body: external_exports.object({ narrative: external_exports.boolean().optional() }),
59753
59772
  description: "Deterministic land-risk assessment (checks/conflicts/restack/diff-size signals) with an optional AI narrative. Works without an AI provider; read-only.",
59754
59773
  returns: '{ riskLevel: "low" | "medium" | "high"; reasons: string[]; signals: Record<string, unknown>; narrative: string | null; model: string | null }'
59755
59774
  }),
@@ -64491,6 +64510,30 @@ var init_acp = __esm({
64491
64510
  }
64492
64511
  });
64493
64512
 
64513
+ // src/domain/issue-refs.ts
64514
+ function extractClosedIssueNumbers(description) {
64515
+ const numbers = /* @__PURE__ */ new Set();
64516
+ for (const m of description.matchAll(CLOSING_KEYWORD_RE)) {
64517
+ const n = Number(m[1]);
64518
+ if (Number.isInteger(n)) numbers.add(n);
64519
+ }
64520
+ return [...numbers];
64521
+ }
64522
+ function describesClosingIssue(description, issueNumber) {
64523
+ return extractClosedIssueNumbers(description).includes(issueNumber);
64524
+ }
64525
+ function findChangeClosingIssue(items, issueNumber) {
64526
+ const matches = items.filter((item) => describesClosingIssue(item.description, issueNumber));
64527
+ return matches.length === 1 ? matches[0] : null;
64528
+ }
64529
+ var CLOSING_KEYWORD_RE;
64530
+ var init_issue_refs = __esm({
64531
+ "src/domain/issue-refs.ts"() {
64532
+ "use strict";
64533
+ CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)\b/gi;
64534
+ }
64535
+ });
64536
+
64494
64537
  // src/acp/commands.ts
64495
64538
  function toSnakeCase2(name) {
64496
64539
  return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
@@ -64689,12 +64732,15 @@ async function serveAcp(api, opts = {}) {
64689
64732
  process.once("SIGINT", resolve);
64690
64733
  });
64691
64734
  }
64692
- var JJHubAcpAgent;
64735
+ var CHANGE_ID_RE, ISSUE_REF_RE, JJHubAcpAgent;
64693
64736
  var init_agent = __esm({
64694
64737
  "src/acp/agent.ts"() {
64695
64738
  "use strict";
64696
64739
  init_acp();
64740
+ init_issue_refs();
64697
64741
  init_commands();
64742
+ CHANGE_ID_RE = /^CHG-\d+$/i;
64743
+ ISSUE_REF_RE = /^#?(\d+)$/;
64698
64744
  JJHubAcpAgent = class {
64699
64745
  constructor(api, conn, opts = {}) {
64700
64746
  this.api = api;
@@ -64749,6 +64795,22 @@ var init_agent = __esm({
64749
64795
  await this.say(sessionId, `error: ${bound.error}`);
64750
64796
  return { stopReason: "end_turn" };
64751
64797
  }
64798
+ const changeIdIdx = cmd.pathParams.indexOf("changeId");
64799
+ if (changeIdIdx !== -1) {
64800
+ const rawChangeId = bound.pathArgs[changeIdIdx];
64801
+ const issueMatch = CHANGE_ID_RE.test(rawChangeId) ? null : rawChangeId.match(ISSUE_REF_RE);
64802
+ if (issueMatch) {
64803
+ const issueNumber = Number(issueMatch[1]);
64804
+ const repositoryIdIdx = cmd.pathParams.indexOf("repositoryId");
64805
+ const repositoryId2 = repositoryIdIdx !== -1 ? bound.pathArgs[repositoryIdIdx] : void 0;
64806
+ const resolved = repositoryId2 ? await this.resolveIssueReference(repositoryId2, issueNumber) : null;
64807
+ if (!resolved) {
64808
+ await this.say(sessionId, `couldn't find a change that closes issue #${issueNumber} in this repo`);
64809
+ return { stopReason: "end_turn" };
64810
+ }
64811
+ bound.pathArgs[changeIdIdx] = resolved.changeId;
64812
+ }
64813
+ }
64752
64814
  if (cmd.requiresForceConfirmation && bound.body.force === true) {
64753
64815
  const confirmed = await this.confirmForce(sessionId, cmd, bound.pathArgs);
64754
64816
  if (this.cancelled.has(sessionId)) return { stopReason: "cancelled" };
@@ -64838,6 +64900,17 @@ var init_agent = __esm({
64838
64900
  return true;
64839
64901
  }
64840
64902
  }
64903
+ /**
64904
+ * Resolve "#123" (or "123") to the change whose description closes that
64905
+ * issue, using only data the agent already fetches over its normal API
64906
+ * surface (list_changes) — no new endpoint or GitHub permission needed.
64907
+ * Ambiguous (>1 match) or absent (0 matches) both resolve to null; the
64908
+ * caller must not guess.
64909
+ */
64910
+ async resolveIssueReference(repositoryId2, issueNumber) {
64911
+ const changes = await this.api.listChanges(repositoryId2);
64912
+ return findChangeClosingIssue(changes, issueNumber);
64913
+ }
64841
64914
  say(sessionId, text) {
64842
64915
  return this.conn.sessionUpdate({
64843
64916
  sessionId,
@@ -93417,7 +93490,7 @@ async function runDaemonOnce(jj, api, repoId, opts = {}) {
93417
93490
  defaultBookmark = (await api.getRepository(repoId)).defaultBookmark;
93418
93491
  } catch {
93419
93492
  }
93420
- const pushEnabled = opts.pushCommits ?? Boolean(process.env.GITHUB_TOKEN);
93493
+ const pushEnabled = opts.pushCommits ?? true;
93421
93494
  let pusher = pushEnabled ? void 0 : null;
93422
93495
  const resolvePusher = async () => {
93423
93496
  if (pusher !== void 0) return pusher;
@@ -93426,16 +93499,38 @@ async function runDaemonOnce(jj, api, repoId, opts = {}) {
93426
93499
  pusher = opts.pushCommit;
93427
93500
  return pusher;
93428
93501
  }
93429
- const token = process.env.GITHUB_TOKEN;
93430
93502
  try {
93431
93503
  const repo = await api.getRepository(repoId);
93432
- if (token && opts.repoPath && GITHUB_FULLNAME_RE.test(repo.fullName)) {
93433
- pusher = makeGitHubPushCommit(opts.repoPath, repo.fullName, token);
93434
- } else {
93504
+ if (!opts.repoPath) {
93505
+ log("[daemon] commit push skipped: no local repo path");
93506
+ return pusher;
93507
+ }
93508
+ if (!GITHUB_FULLNAME_RE.test(repo.fullName)) {
93509
+ log(
93510
+ `[daemon] commit push skipped: repo fullName "${repo.fullName}" does not look like a GitHub repo`
93511
+ );
93512
+ return pusher;
93513
+ }
93514
+ let credential;
93515
+ try {
93516
+ credential = await api.getPushCredential(repoId);
93517
+ } catch (err) {
93518
+ if (isMissingEndpoint(err)) {
93519
+ log(
93520
+ "[daemon] commit push skipped: server does not support push credentials yet (upgrade the server)"
93521
+ );
93522
+ } else {
93523
+ log(`[daemon] commit push disabled: ${err instanceof Error ? err.message : String(err)}`);
93524
+ }
93525
+ return pusher;
93526
+ }
93527
+ if (!credential) {
93435
93528
  log(
93436
- `[daemon] commit push skipped: ${!token ? "no GITHUB_TOKEN" : !opts.repoPath ? "no local repo path" : `repo fullName "${repo.fullName}" does not look like a GitHub repo`}`
93529
+ "[daemon] commit push skipped: install the JJHub GitHub App on this repository to enable real-commit push"
93437
93530
  );
93531
+ return pusher;
93438
93532
  }
93533
+ pusher = makeGitHubPushCommit(opts.repoPath, repo.fullName, credential.token);
93439
93534
  } catch (err) {
93440
93535
  log(`[daemon] commit push disabled: ${err instanceof Error ? err.message : String(err)}`);
93441
93536
  }
@@ -93741,8 +93836,9 @@ Usage: jjhub <command> [args] [--repo owner/name|repoId] [--json]
93741
93836
  change abandon <changeId>
93742
93837
  change restack <changeId> rebase a needs-restack change onto its rewritten ancestor
93743
93838
  change land <changeId> [--force] --force bypasses a failing/pending check-run gate
93744
- change commit <changeId> <file...> [-m msg]
93745
- commit working-tree files onto the change's branch
93839
+ change commit <changeId> [<file...>] [-m msg] [--delete <path>]...
93840
+ commit working-tree files onto the change's branch;
93841
+ --delete removes a path in the same commit (repeatable)
93746
93842
  change diff <changeId> per-file diffs of what the change contains
93747
93843
  change split <changeId> <newTitle>
93748
93844
  change checks <changeId> list GitHub check runs for a change
@@ -93860,7 +93956,8 @@ async function runCli(argv, opts = {}) {
93860
93956
  "two-way": { type: "boolean", default: false },
93861
93957
  members: { type: "string" },
93862
93958
  "github-token": { type: "string" },
93863
- force: { type: "boolean", default: false }
93959
+ force: { type: "boolean", default: false },
93960
+ delete: { type: "string", multiple: true }
93864
93961
  }
93865
93962
  });
93866
93963
  positionals = parsed.positionals;
@@ -94175,19 +94272,23 @@ async function changeCmd(ctx, pos, values) {
94175
94272
  }
94176
94273
  case "commit": {
94177
94274
  const [changeId2, ...paths] = rest;
94178
- if (!changeId2 || paths.length === 0) {
94179
- return usageError(ctx, "change commit <changeId> <file...> [-m message]");
94275
+ const deletePaths = values.delete;
94276
+ if (!changeId2 || paths.length === 0 && (!deletePaths || deletePaths.length === 0)) {
94277
+ return usageError(ctx, "change commit <changeId> [<file...>] [-m message] [--delete <path>]...");
94180
94278
  }
94181
94279
  const { readFileSync: readFileSync4 } = await import("node:fs");
94182
94280
  const files = paths.map((p) => ({ path: p, content: readFileSync4(p, "utf-8") }));
94183
94281
  const change = await ctx.api.commitFiles(await ctx.repoId(), changeId2, {
94184
94282
  message: values.message,
94185
- files
94283
+ files,
94284
+ deletePaths
94186
94285
  });
94187
94286
  if (ctx.json) return emitJson(ctx, change);
94188
- ctx.out(
94189
- `committed ${files.length} file${files.length === 1 ? "" : "s"} to ${change.changeId} (commit ${change.currentCommitSha})`
94190
- );
94287
+ const parts = [
94288
+ files.length > 0 ? `wrote ${files.length} file${files.length === 1 ? "" : "s"}` : null,
94289
+ deletePaths && deletePaths.length > 0 ? `deleted ${deletePaths.length} file${deletePaths.length === 1 ? "" : "s"}` : null
94290
+ ].filter((p) => p !== null);
94291
+ ctx.out(`${parts.join(", ")} in ${change.changeId} (commit ${change.currentCommitSha})`);
94191
94292
  return 0;
94192
94293
  }
94193
94294
  case "diff": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jjhub",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "jjhub — GitHub CLI with Jujutsu-native superpowers: stable change IDs, stacks, platform-wide undo, and conflicts that wait, layered over plain GitHub repos by a JJHub server.",
5
5
  "bin": {
6
6
  "jjhub": "./dist/cli/jjhub.mjs"
@@ -59,8 +59,9 @@ change amend <changeId>
59
59
  change squash <changeId>
60
60
  change abandon <changeId>
61
61
  change restack <changeId> rebase a needs-restack change onto its rewritten ancestor
62
- change commit <changeId> <file...> [-m msg]
63
- commit working-tree files onto the change's branch
62
+ change commit <changeId> [<file...>] [-m msg] [--delete <path>]...
63
+ commit working-tree files onto the change's branch;
64
+ --delete removes a path in the same commit (repeatable)
64
65
  change diff <changeId> per-file diffs of what the change contains
65
66
  change files <changeId> list locally-stored files (contentMode "local" repos only)
66
67
  change land <changeId> [--force] waits for real CI triggered by its own ready flip,