@bensandee/forgejo-cli 0.3.2 → 0.4.1

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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/dist/bin.mjs +407 -42
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @bensandee/forgejo-cli
2
2
 
3
3
  `bst-fj`: the Forgejo API commands, without the rest of `bst`. Most of these commands used to ship on
4
- `@bensandee/tooling`'s `bst` bin; `issue:*`, `pr:create` and `pr:update` still do, and run the same code.
4
+ `@bensandee/tooling`'s `bst` bin; `issue:*`, `pr:create`, `pr:update`, `pr:ready` and `pr:retarget` still do, and run the same code.
5
5
 
6
6
  ## Installation
7
7
 
@@ -54,10 +54,12 @@ command takes `--json`, which prints exactly one JSON document, failures include
54
54
  | `branch:delete` | Delete a branch through the API; reports the forge's reason when it refuses |
55
55
  | `pr:files` | List the files a pull request changes |
56
56
  | `pr:merge` | Merge an open, non-draft, mergeable PR pinned to the head it read; refuses closed, draft, conflicted, unconfirmed or non-green checks (zero statuses included) unless `--ignore-checks`; `--delete-branch` is opt-in |
57
+ | `pr:ready` | Mark a draft pull request ready, dropping one `WIP:` prefix or taking `--title`, and confirm the forge no longer reports a draft |
58
+ | `pr:retarget` | Repoint an open pull request at a new base branch, confirm the forge took it, and report its mergeability as advice |
57
59
  | `workflow:dispatch` | Dispatch a workflow with `--workflow`, `--ref` and repeatable `--input`; takes `--repo owner/repo`, not a URL |
58
60
  | `ci:runs` | List a repository's workflow runs with their jobs, filtered by workflow, ref, pull request, head SHA or status |
59
61
  | `ci:log` | Print one workflow job's whole log, or with `--grep` only the lines whose text matches |
60
62
 
61
63
  Zero posted statuses reads as `no-status`, never as green. Every command is Forgejo-only: on
62
- GitHub, use `gh`. The `issue:*`, `pr:create` and `pr:update` commands take the same flags and print the
64
+ GitHub, use `gh`. The `issue:*`, `pr:create`, `pr:update`, `pr:ready` and `pr:retarget` commands take the same flags and print the
63
65
  same `--json` documents as `bst`'s, except that `issue:create` has no config default for assignees.
package/dist/bin.mjs CHANGED
@@ -245,7 +245,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
245
245
  //#region src/version.ts
246
246
  const parsed = z.object({ version: z.string() }).safeParse({
247
247
  name: "@bensandee/forgejo-cli",
248
- version: "0.3.2",
248
+ version: "0.4.1",
249
249
  description: "bst-fj: Forgejo API commands without the rest of bst",
250
250
  license: "MIT",
251
251
  repository: {
@@ -280,7 +280,7 @@ const parsed = z.object({ version: z.string() }).safeParse({
280
280
  devDependencies: {
281
281
  "@bensandee/cli-kit": "workspace:*",
282
282
  "@bensandee/forgejo": "workspace:*",
283
- "@types/node": "24.13.5",
283
+ "@types/node": "24.19.1",
284
284
  "tsdown": "0.23.0"
285
285
  }
286
286
  });
@@ -1936,6 +1936,26 @@ async function getBranchHeadSha(executor, conn, branch) {
1936
1936
  if (!parsed.success) throw new UnexpectedError(`Unexpected branch response: ${parsed.error.message}`);
1937
1937
  return parsed.data.commit.id;
1938
1938
  }
1939
+ /**
1940
+ * Whether the forge knows this branch at all -- the probe a caller runs before it asks the forge
1941
+ * to point a pull request at a branch.
1942
+ *
1943
+ * {@link getBranchHeadSha} cannot serve as this probe: its 404 goes through `throwForgejoFailure`
1944
+ * and throws `ForgeAccessError`, the same class a 403 throws, so a caller catching it would read a
1945
+ * token that cannot see the repository as a branch that was never pushed. Only a 404 answers
1946
+ * `false` here; every other non-ok status throws, because "I cannot tell" is never "it is
1947
+ * missing".
1948
+ */
1949
+ async function branchExists(executor, conn, branch) {
1950
+ const url = `${repoBase$1(conn)}/branches/${encodeURIComponent(branch)}`;
1951
+ const res = await executor.fetch(url, { headers: authHeaders$1(conn) });
1952
+ if (res.status === 404) return false;
1953
+ if (!res.ok) await throwForgejoFailure(res, {
1954
+ verb: "check branch",
1955
+ resource: branch
1956
+ }, branchNotFoundMessage(conn, branch));
1957
+ return true;
1958
+ }
1939
1959
  //#endregion
1940
1960
  //#region ../cli-kit/src/git.ts
1941
1961
  const BRANCH_REF_PREFIX = "refs/heads/";
@@ -2029,6 +2049,15 @@ function resolveRepoUrl(cwd) {
2029
2049
  return resolveRepoUrlFrom(() => readOriginUrl(cwd), () => readPackageRepositoryUrl(cwd));
2030
2050
  }
2031
2051
  /**
2052
+ * The connection a `--repo` value names. Every command in this bin needs both halves -- parse the
2053
+ * value, then resolve it against the working directory -- and this module already owns the second
2054
+ * and already imports the module the first lives in, so the pair is composed once here rather than
2055
+ * repeated per command.
2056
+ */
2057
+ function requireForgejoTargetFromRepo(cwd, repo, githubRefusal, env = process.env) {
2058
+ return requireForgejoTarget(cwd, parseRepoTarget(repo), githubRefusal, env);
2059
+ }
2060
+ /**
2032
2061
  * The Forgejo connection a command acts on. An explicit target (from an issue URL or `--repo`)
2033
2062
  * wins over the working directory. `githubRefusal` is the command's own sentence for a github.com
2034
2063
  * repository, which owes the reader the `gh` alternative. The environment settles the connection
@@ -5094,7 +5123,8 @@ const CommentResponseSchema = z.object({
5094
5123
  user: z.object({ login: z.string() }).nullish(),
5095
5124
  created_at: z.string(),
5096
5125
  updated_at: z.string().nullish(),
5097
- html_url: z.string().nullish()
5126
+ html_url: z.string().nullish(),
5127
+ assets: AttachmentListSchema.nullish()
5098
5128
  });
5099
5129
  const CommentListSchema = z.array(CommentResponseSchema);
5100
5130
  function parseCommentOrThrow(raw) {
@@ -5114,7 +5144,8 @@ function normalizeComment(raw) {
5114
5144
  createdAt: raw.created_at,
5115
5145
  updatedAt: raw.updated_at ?? raw.created_at,
5116
5146
  body: raw.body ?? "",
5117
- ...raw.html_url === void 0 || raw.html_url === null ? {} : { url: raw.html_url }
5147
+ ...raw.html_url === void 0 || raw.html_url === null ? {} : { url: raw.html_url },
5148
+ attachments: (raw.assets ?? []).map(normalizeAttachment)
5118
5149
  };
5119
5150
  }
5120
5151
  /**
@@ -5172,17 +5203,6 @@ const RepoCommentResponseSchema = CommentResponseSchema.extend({
5172
5203
  updated_at: z.string().nullish()
5173
5204
  });
5174
5205
  z.array(RepoCommentResponseSchema);
5175
- /** List a comment's attachments. */
5176
- async function listForgejoCommentAssets(executor, conn, commentId) {
5177
- const res = await executor.fetch(`${issuesBase(conn)}/comments/${String(commentId)}/assets`, { headers: authHeaders$2(conn) });
5178
- if (!res.ok) await throwForgejoFailure(res, {
5179
- verb: "list comment attachments",
5180
- resource: `#${String(commentId)}`
5181
- }, `Comment #${String(commentId)} not found in ${conn.repository}`);
5182
- const parsed = AttachmentListSchema.safeParse(await res.json());
5183
- if (!parsed.success) throw new UnexpectedError(`Unexpected attachment-list response: ${parsed.error.message}`);
5184
- return parsed.data.map(normalizeAttachment);
5185
- }
5186
5206
  /** Post a new comment on an issue. */
5187
5207
  async function addForgejoComment(executor, conn, number, body) {
5188
5208
  const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/comments`, {
@@ -5302,6 +5322,9 @@ const PullListItemSchema = z.object({
5302
5322
  updated_at: z.string().nullish(),
5303
5323
  labels: z.array(z.object({ name: z.string() })).nullish()
5304
5324
  });
5325
+ function updatedBefore(since) {
5326
+ return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
5327
+ }
5305
5328
  /**
5306
5329
  * Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
5307
5330
  * may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
@@ -5329,7 +5352,9 @@ async function listPulls(executor, conn, options = {}) {
5329
5352
  limit: String(PULLS_PAGE_SIZE),
5330
5353
  page: String(page)
5331
5354
  });
5332
- if (options.sort !== void 0) params.set("sort", options.sort);
5355
+ const sort = options.updatedSince === void 0 ? options.sort : "recentupdate";
5356
+ if (sort !== void 0) params.set("sort", sort);
5357
+ for (const id of options.labelIds ?? []) params.append("labels", String(id));
5333
5358
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?${params.toString()}`;
5334
5359
  const res = await executor.fetch(url, {
5335
5360
  headers,
@@ -5358,6 +5383,7 @@ async function listPulls(executor, conn, options = {}) {
5358
5383
  pulls.push(...items);
5359
5384
  if (items.length === 0) break;
5360
5385
  if (options.stopWhen !== void 0 && items.some(options.stopWhen)) break;
5386
+ if (options.updatedSince !== void 0 && items.some(updatedBefore(options.updatedSince))) break;
5361
5387
  if (options.limit !== void 0 && pulls.length >= options.limit) break;
5362
5388
  if (page === PULLS_MAX_PAGES) {
5363
5389
  log.warn(`PR listing on ${conn.repository} hit the ${String(PULLS_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
@@ -5472,6 +5498,63 @@ function normalizePrDetail(data) {
5472
5498
  async function getForgejoPrDetail(executor, conn, prNumber) {
5473
5499
  return normalizePrDetail(await readPrField(executor, conn, prNumber, PrDetailSchema, "detail"));
5474
5500
  }
5501
+ const PrEditStateSchema = z.object({
5502
+ state: z.string(),
5503
+ merged: z.boolean(),
5504
+ draft: z.boolean(),
5505
+ title: z.string(),
5506
+ head: z.object({ ref: z.string() }),
5507
+ base: z.object({ ref: z.string() }),
5508
+ mergeable: z.boolean().nullish(),
5509
+ html_url: z.string().optional()
5510
+ });
5511
+ /**
5512
+ * Read a pull request's edit state in one `/pulls/<n>` GET -- the read both editors run before
5513
+ * they write, and the one they run again to confirm the write took.
5514
+ *
5515
+ * `url` prefers the forge's own `html_url` because an instance that serves it knows its real web
5516
+ * route (a reverse-proxied path included), and falls back to {@link prWebUrl} when it does not.
5517
+ * `mergeable` stays tri-state for {@link PrDetail}'s reason: absent is "still computing", not
5518
+ * "conflicted".
5519
+ */
5520
+ async function getPrEditState(executor, conn, prNumber) {
5521
+ const parsed = await readPrField(executor, conn, prNumber, PrEditStateSchema, "edit state");
5522
+ return {
5523
+ state: parsed.state,
5524
+ merged: parsed.merged,
5525
+ draft: parsed.draft,
5526
+ title: parsed.title,
5527
+ head: parsed.head.ref,
5528
+ base: parsed.base.ref,
5529
+ mergeable: parsed.mergeable ?? null,
5530
+ url: parsed.html_url ?? prWebUrl(conn, prNumber)
5531
+ };
5532
+ }
5533
+ /**
5534
+ * Repoint a pull request at a new base branch with a PATCH carrying `base` and nothing else.
5535
+ *
5536
+ * Its own function rather than another field on {@link UpdatePrOptions}: that interface is the
5537
+ * title-and-body pair whose whole contract is "an omitted key is left alone", and widening it to
5538
+ * carry a branch name would let a caller that only means to fix a title move a pull request's
5539
+ * base by accident. The caller confirms the write by re-reading -- Forgejo accepts an unknown
5540
+ * `EditPullRequestOption` field silently, so a PATCH that answered 200 is not proof the base
5541
+ * moved.
5542
+ */
5543
+ async function retargetPr(executor, conn, prNumber, base) {
5544
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
5545
+ const res = await executor.fetch(url, {
5546
+ method: "PATCH",
5547
+ headers: {
5548
+ Authorization: `token ${conn.token}`,
5549
+ "Content-Type": "application/json"
5550
+ },
5551
+ body: JSON.stringify({ base })
5552
+ }, { idempotent: true });
5553
+ if (!res.ok) await throwForgejoFailure(res, {
5554
+ verb: "retarget PR",
5555
+ resource: `#${String(prNumber)}`
5556
+ });
5557
+ }
5475
5558
  const PrContentSchema = z.object({
5476
5559
  title: z.string(),
5477
5560
  body: z.string().nullish()
@@ -6212,6 +6295,12 @@ function resolveTargetPath(outDir, name, taken) {
6212
6295
  * login page with a 200 status. Verifying the length is what stops that login page
6213
6296
  * from landing on disk named `plan.md`, where a reader would then act on it.
6214
6297
  * Nothing is written until the bytes check out.
6298
+ *
6299
+ * Exported because a caller that wants the bytes in memory rather than on disk -- one
6300
+ * collecting an issue's images for a container -- needs exactly this check and has no way
6301
+ * to write it without the same reasoning about the login page. It lives here, beside its
6302
+ * only other caller, rather than in a new module: `packages/forgejo/src` sits at its
6303
+ * crowding ceiling.
6215
6304
  */
6216
6305
  async function downloadAttachment(fetcher, token, attachment) {
6217
6306
  const res = await fetcher.fetch(attachment.downloadUrl, { headers: { Authorization: `token ${token}` } }, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
@@ -6235,8 +6324,7 @@ async function downloadIssueAttachments(resolve, request) {
6235
6324
  const files = request.files ?? diskFiles;
6236
6325
  const issue = await getForgejoIssue(fetcher, conn, number);
6237
6326
  const comments = await listForgejoComments(fetcher, conn, number);
6238
- const commentAttachments = await Promise.all(comments.map((comment) => listForgejoCommentAssets(fetcher, conn, comment.id)));
6239
- const attachments = [...issue.attachments, ...commentAttachments.flat()];
6327
+ const attachments = [...issue.attachments, ...comments.flatMap((comment) => comment.attachments)];
6240
6328
  const outDir = request.outDir;
6241
6329
  const taken = /* @__PURE__ */ new Set();
6242
6330
  const downloaded = [];
@@ -8099,6 +8187,20 @@ function forgejoPrHost(fetcher, conn) {
8099
8187
  }
8100
8188
  };
8101
8189
  }
8190
+ /**
8191
+ * The Forgejo {@link PrEditHost}, over the API calls in `pr.ts` and `repo.ts`. Its `ready` is a
8192
+ * title-only PATCH and never a body write: the body is the author's text, and the draft flag has
8193
+ * no PATCH field of its own on this forge.
8194
+ */
8195
+ function forgejoPrEditHost(fetcher, conn) {
8196
+ return {
8197
+ wipTitles: true,
8198
+ readLive: (number) => getPrEditState(fetcher, conn, number),
8199
+ branchExists: (name) => branchExists(fetcher, conn, name),
8200
+ setBase: (number, base) => retargetPr(fetcher, conn, number, base),
8201
+ ready: (number, write) => updatePr(fetcher, conn, number, { title: write.title })
8202
+ };
8203
+ }
8102
8204
  //#endregion
8103
8205
  //#region src/git.ts
8104
8206
  /** Exit codes reported when this module ends a child itself, following the shell's convention. */
@@ -8331,16 +8433,22 @@ async function listPrFiles(executor, conn, prNumber, options) {
8331
8433
  };
8332
8434
  }
8333
8435
  //#endregion
8334
- //#region src/pr-files.ts
8436
+ //#region ../forgejo/src/pr-number.ts
8335
8437
  /**
8336
- * `bst-fj pr:files`: the files a pull request changes, over `@bensandee/forgejo/pr-files`.
8337
- * Forgejo-only; `gh pr diff --name-only` already serves GitHub.
8438
+ * The one parser for a pull request number given as text, shared by every bin's positional so a
8439
+ * malformed number refuses the same way whether a person or a script typed it.
8338
8440
  */
8339
8441
  function parsePrNumber(raw) {
8340
8442
  const value = Number(raw);
8341
8443
  if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1) throw new FatalError(`Invalid pull request number "${raw}" (expected a positive integer).`);
8342
8444
  return value;
8343
8445
  }
8446
+ //#endregion
8447
+ //#region src/pr-files.ts
8448
+ /**
8449
+ * `bst-fj pr:files`: the files a pull request changes, over `@bensandee/forgejo/pr-files`.
8450
+ * Forgejo-only; `gh pr diff --name-only` already serves GitHub.
8451
+ */
8344
8452
  /** List the files a pull request changes. */
8345
8453
  function runPrFiles(fetcher, options) {
8346
8454
  const number = parsePrNumber(options.number);
@@ -8685,16 +8793,237 @@ function renderPrMerge(result) {
8685
8793
  return result.merged ? `Merged ${what}.` : `Dry run: would merge ${what}.`;
8686
8794
  }
8687
8795
  //#endregion
8796
+ //#region src/commands/pr-merge.ts
8797
+ const prMergeCommand = defineCommand({
8798
+ meta: {
8799
+ name: "pr:merge",
8800
+ description: "Merge an open, non-draft pull request the forge confirms is mergeable and whose checks are green, pinned to the head SHA it read (Forgejo-only; use gh pr merge on GitHub)"
8801
+ },
8802
+ args: {
8803
+ number: {
8804
+ type: "positional",
8805
+ required: true,
8806
+ description: "Pull request number"
8807
+ },
8808
+ method: {
8809
+ type: "string",
8810
+ description: "Merge method: merge, rebase or squash (default: merge)"
8811
+ },
8812
+ "delete-branch": {
8813
+ type: "boolean",
8814
+ description: "Delete the head branch after the merge"
8815
+ },
8816
+ "ignore-checks": {
8817
+ type: "boolean",
8818
+ description: "Merge even when the head's combined status is not green"
8819
+ },
8820
+ "dry-run": {
8821
+ type: "boolean",
8822
+ description: "Run every check and report, but do not merge"
8823
+ },
8824
+ repo: {
8825
+ type: "string",
8826
+ description: "Repository URL (default: this repo)"
8827
+ },
8828
+ json: {
8829
+ type: "boolean",
8830
+ description: "Emit one JSON document: number, merged, method (merge | rebase | squash), headSha, verdict (mergeable), checks (green | red | pending | no-status | unparseable). merged is false on a dry run; a refusal is an error, never this document."
8831
+ },
8832
+ verbose: {
8833
+ type: "boolean",
8834
+ description: "Emit detailed debug output"
8835
+ }
8836
+ },
8837
+ async run({ args }) {
8838
+ const out = createCliOutput(args.json === true);
8839
+ const result = await runPrMerge(cliFetcher(), {
8840
+ cwd: process.cwd(),
8841
+ number: args.number,
8842
+ method: args.method ?? void 0,
8843
+ deleteBranch: args["delete-branch"] === true,
8844
+ ignoreChecks: args["ignore-checks"] === true,
8845
+ dryRun: args["dry-run"] === true,
8846
+ repo: args.repo ?? void 0
8847
+ });
8848
+ out.json(result);
8849
+ out.text(renderPrMerge(result));
8850
+ }
8851
+ });
8852
+ //#endregion
8853
+ //#region ../forgejo/src/pr-edit.ts
8854
+ /**
8855
+ * The two pull-request editing engines, forge-generic: what a base change and a ready write check,
8856
+ * send, and confirm. Each one drives a {@link PrEditHost}, so the sequence is written once and only
8857
+ * the writes differ per forge -- the refusals and the confirming re-read are the parts a person
8858
+ * relies on, and they must not be re-derived on each side of the seam.
8859
+ *
8860
+ * Engines and renderers share this module because every importer of either is the same command: a
8861
+ * renderer kept apart would be a second module for the same four callers.
8862
+ */
8863
+ /** The wait before the one mergeability re-read after a retarget; both bins pass it. */
8864
+ const MERGEABLE_RECHECK_DELAY_MS = 2e3;
8865
+ /** What both forges call an editable pull request. One predicate, so the two engines cannot
8866
+ * disagree about it and a caller cannot read "open" as its own looser thing. */
8867
+ function isOpen(live) {
8868
+ return live.state === "open" && live.merged === false;
8869
+ }
8870
+ function notOpenMessage(prNumber, live) {
8871
+ const what = live.merged ? "merged" : live.state;
8872
+ return `Pull request #${String(prNumber)} is ${what}, so it has no base or draft state left to change. Reopen it first if this work is still live.`;
8873
+ }
8874
+ /**
8875
+ * Point an open pull request at a new base branch, then prove the forge took it.
8876
+ *
8877
+ * The re-read is the whole point of the command: a forge that does not implement the base field
8878
+ * answers the PATCH with 200 and changes nothing, so a report built from the request would claim a
8879
+ * move that never happened. `mergeable` rides along as advice only and never decides the outcome --
8880
+ * it is tri-state on both forges and a pull request whose base just moved is being recomputed by
8881
+ * definition -- so a `false` earns exactly one more read after a wait, and `null` and `true` are
8882
+ * taken as final.
8883
+ */
8884
+ async function runPrRetarget(host, options) {
8885
+ const live = await host.readLive(options.number);
8886
+ if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
8887
+ if (options.base === live.head) throw new FatalError(`Pull request #${String(options.number)} cannot target its own head branch "${options.base}" -- choose a branch it is meant to merge into.`);
8888
+ if (options.base === live.base) return {
8889
+ action: "unchanged",
8890
+ number: options.number,
8891
+ url: live.url,
8892
+ previousBase: live.base,
8893
+ base: live.base,
8894
+ head: live.head,
8895
+ mergeable: live.mergeable,
8896
+ dryRun: options.dryRun
8897
+ };
8898
+ if (!await host.branchExists(options.base)) throw new FatalError(`Branch "${options.base}" does not exist in this repository, so pull request #${String(options.number)} cannot target it -- push the branch, or pass a base the forge has.`);
8899
+ if (options.dryRun) return {
8900
+ action: "would-retarget",
8901
+ number: options.number,
8902
+ url: live.url,
8903
+ previousBase: live.base,
8904
+ base: options.base,
8905
+ head: live.head,
8906
+ mergeable: live.mergeable,
8907
+ dryRun: true
8908
+ };
8909
+ await host.setBase(options.number, options.base);
8910
+ const recheck = await host.readLive(options.number);
8911
+ if (recheck.base !== options.base) throw new FatalError(`Pull request #${String(options.number)} still reports its base as "${recheck.base}" after the request to move it to "${options.base}" -- this forge accepted the field and ignored it, so change the base in its web interface.`);
8912
+ let mergeable = recheck.mergeable;
8913
+ if (mergeable === false) {
8914
+ await options.sleep(options.recheckDelayMs);
8915
+ mergeable = (await host.readLive(options.number)).mergeable;
8916
+ }
8917
+ return {
8918
+ action: "retargeted",
8919
+ number: options.number,
8920
+ url: recheck.url,
8921
+ previousBase: live.base,
8922
+ base: options.base,
8923
+ head: live.head,
8924
+ mergeable,
8925
+ dryRun: false
8926
+ };
8927
+ }
8928
+ /**
8929
+ * Mark an open pull request ready, dropping its WIP marker or writing the title given.
8930
+ *
8931
+ * The draft is the forge's flag, never the title prefix: a prefix is how a title reads and a draft
8932
+ * is what the forge refuses to merge, and the two disagree on both forges. So the title is the
8933
+ * mechanism (it is the only lever Forgejo's PATCH has) and the flag is the question of whether
8934
+ * there is anything to do -- which is also why a successful write is re-read rather than trusted.
8935
+ * A draft whose title carries no marker this side recognises is refused rather than readied by
8936
+ * guessing at one: on that forge only the title can clear the flag, and an invented title is a
8937
+ * rewrite of someone's headline.
8938
+ */
8939
+ async function runPrReady(host, options) {
8940
+ const live = await host.readLive(options.number);
8941
+ if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
8942
+ const given = options.title;
8943
+ if (given !== void 0) {
8944
+ if (given.trim() === "") throw new FatalError(`--title is empty -- pass the title to write, or omit --title to drop the WIP marker from pull request #${String(options.number)}'s current title.`);
8945
+ if (hasWipPrefix(given)) {
8946
+ const reason = host.wipTitles ? "which on this forge keeps the pull request a draft" : "so the pull request would still read as work in progress";
8947
+ throw new FatalError(`--title ${JSON.stringify(given)} carries a WIP marker, ${reason} -- pass the title you want it to read without one.`);
8948
+ }
8949
+ }
8950
+ const finalTitle = given ?? stripWipPrefix(live.title);
8951
+ if (finalTitle.trim() === "") throw new FatalError(`Pull request #${String(options.number)} would be left with an empty title, which neither forge accepts -- pass --title with the title to write.`);
8952
+ if (host.wipTitles && live.draft && given === void 0 && !hasWipPrefix(live.title)) throw new FatalError(`Pull request #${String(options.number)} is a draft and its title ${JSON.stringify(live.title)} carries no WIP marker this tool recognises, so there is nothing to strip -- pass --title "<title>" to write the title that clears the draft.`);
8953
+ const titleChanged = finalTitle !== live.title;
8954
+ if (!live.draft && !titleChanged) return {
8955
+ action: "already-ready",
8956
+ number: options.number,
8957
+ url: live.url,
8958
+ previousTitle: live.title,
8959
+ title: live.title,
8960
+ draft: live.draft,
8961
+ dryRun: options.dryRun
8962
+ };
8963
+ if (options.dryRun) return {
8964
+ action: live.draft ? "would-ready" : "would-retitle",
8965
+ number: options.number,
8966
+ url: live.url,
8967
+ previousTitle: live.title,
8968
+ title: finalTitle,
8969
+ draft: live.draft,
8970
+ dryRun: true
8971
+ };
8972
+ await host.ready(options.number, {
8973
+ draft: live.draft,
8974
+ title: finalTitle,
8975
+ titleChanged
8976
+ });
8977
+ const recheck = await host.readLive(options.number);
8978
+ if (recheck.draft) throw new FatalError(`Pull request #${String(options.number)} is still a draft after the title ${JSON.stringify(finalTitle)} was written -- its draft state is set by something other than the title, so clear it in the forge's interface.`);
8979
+ return {
8980
+ action: live.draft ? "readied" : "retitled",
8981
+ number: options.number,
8982
+ url: recheck.url,
8983
+ previousTitle: live.title,
8984
+ title: recheck.title,
8985
+ draft: recheck.draft,
8986
+ dryRun: false
8987
+ };
8988
+ }
8989
+ const PR_RETARGET_JSON_HELP = "Emit one JSON document: action (retargeted | unchanged | would-retarget), number, url, previousBase, base, head, mergeable (true | false | null, advice only), dryRun; a refusal is an error, never this document.";
8990
+ const PR_READY_JSON_HELP = "Emit one JSON document: action (readied | retitled | already-ready | would-ready | would-retitle), number, url, previousTitle, title, draft, dryRun; a refusal is an error, never this document.";
8991
+ function mergeabilityLine(mergeable) {
8992
+ if (mergeable === true) return "Mergeability: mergeable.";
8993
+ if (mergeable === false) return "Mergeability: not mergeable -- advice, not a verdict.";
8994
+ return "Mergeability: unknown -- the forge has not computed it.";
8995
+ }
8996
+ /** What happened to the base, and the mergeability reading as advice. */
8997
+ function renderPrRetarget(result) {
8998
+ const number = `#${String(result.number)}`;
8999
+ const lines = [];
9000
+ if (result.action === "retargeted") lines.push(`Pull request ${number} now targets "${result.base}" (was "${result.previousBase}").`);
9001
+ else if (result.action === "unchanged") lines.push(`Pull request ${number} already targets "${result.base}".`);
9002
+ else lines.push(`Dry run: pull request ${number} would move from "${result.previousBase}" to "${result.base}".`);
9003
+ lines.push(mergeabilityLine(result.mergeable));
9004
+ return lines.join("\n");
9005
+ }
9006
+ /** What happened to the draft state and the title. */
9007
+ function renderPrReady(result) {
9008
+ const number = `#${String(result.number)}`;
9009
+ if (result.action === "readied") return `Pull request ${number} is no longer a draft, reading as "${result.title}".`;
9010
+ if (result.action === "retitled") return `Pull request ${number} now reads "${result.title}".`;
9011
+ if (result.action === "already-ready") return `Pull request ${number} is already ready and reads "${result.title}".`;
9012
+ if (result.action === "would-ready") return `Dry run: pull request ${number} would stop being a draft, reading "${result.title}".`;
9013
+ return `Dry run: pull request ${number} would read "${result.title}".`;
9014
+ }
9015
+ //#endregion
8688
9016
  //#region src/registry-pr.ts
8689
9017
  const prSubCommands = {
8690
9018
  "pr:checks": prChecksCommand,
8691
9019
  "pr:create": prCreateCommand,
8692
9020
  "pr:files": prFilesCommand,
8693
9021
  "pr:list": prListCommand,
8694
- "pr:merge": defineCommand({
9022
+ "pr:merge": prMergeCommand,
9023
+ "pr:ready": defineCommand({
8695
9024
  meta: {
8696
- name: "pr:merge",
8697
- description: "Merge an open, non-draft pull request the forge confirms is mergeable and whose checks are green, pinned to the head SHA it read (Forgejo-only; use gh pr merge on GitHub)"
9025
+ name: "pr:ready",
9026
+ description: "Mark a draft pull request ready, dropping one WIP: prefix or taking --title, and confirm the forge no longer reports a draft (Forgejo-only; use gh pr ready on GitHub)"
8698
9027
  },
8699
9028
  args: {
8700
9029
  number: {
@@ -8702,29 +9031,66 @@ const prSubCommands = {
8702
9031
  required: true,
8703
9032
  description: "Pull request number"
8704
9033
  },
8705
- method: {
9034
+ title: {
9035
+ type: "string",
9036
+ description: "Write this title instead of stripping the WIP prefix from the current one"
9037
+ },
9038
+ repo: {
8706
9039
  type: "string",
8707
- description: "Merge method: merge, rebase or squash (default: merge)"
9040
+ description: "Repository URL (default: this repo)"
8708
9041
  },
8709
- "delete-branch": {
9042
+ "dry-run": {
8710
9043
  type: "boolean",
8711
- description: "Delete the head branch after the merge"
9044
+ description: "Run every read and report, but do not write"
8712
9045
  },
8713
- "ignore-checks": {
9046
+ json: {
8714
9047
  type: "boolean",
8715
- description: "Merge even when the head's combined status is not green"
9048
+ description: PR_READY_JSON_HELP
8716
9049
  },
8717
- "dry-run": {
9050
+ verbose: {
8718
9051
  type: "boolean",
8719
- description: "Run every check and report, but do not merge"
9052
+ description: "Emit detailed debug output"
9053
+ }
9054
+ },
9055
+ async run({ args }) {
9056
+ const out = createCliOutput(args.json === true);
9057
+ const conn = requireForgejoTargetFromRepo(process.cwd(), args.repo ?? void 0, "pr:ready is Forgejo-only -- use gh pr ready <n> on GitHub.");
9058
+ const result = await runPrReady(forgejoPrEditHost(cliFetcher(), conn), {
9059
+ number: parsePrNumber(args.number),
9060
+ title: args.title ?? void 0,
9061
+ dryRun: args["dry-run"] === true
9062
+ });
9063
+ out.json(result);
9064
+ out.text(renderPrReady(result));
9065
+ }
9066
+ }),
9067
+ "pr:retarget": defineCommand({
9068
+ meta: {
9069
+ name: "pr:retarget",
9070
+ description: "Change an open pull request's base branch, confirm the forge took it, and report its mergeability as advice (Forgejo-only; use gh pr edit on GitHub)"
9071
+ },
9072
+ args: {
9073
+ number: {
9074
+ type: "positional",
9075
+ required: true,
9076
+ description: "Pull request number"
9077
+ },
9078
+ base: {
9079
+ type: "string",
9080
+ required: true,
9081
+ description: "The branch to retarget the pull request onto"
8720
9082
  },
8721
9083
  repo: {
8722
9084
  type: "string",
8723
9085
  description: "Repository URL (default: this repo)"
8724
9086
  },
9087
+ "dry-run": {
9088
+ type: "boolean",
9089
+ description: "Run every read and report, but do not write"
9090
+ },
8725
9091
  json: {
8726
9092
  type: "boolean",
8727
- description: "Emit one JSON document: number, merged, method (merge | rebase | squash), headSha, verdict (mergeable), checks (green | red | pending | no-status | unparseable). merged is false on a dry run; a refusal is an error, never this document."
9093
+ description: PR_RETARGET_JSON_HELP
8728
9094
  },
8729
9095
  verbose: {
8730
9096
  type: "boolean",
@@ -8733,17 +9099,16 @@ const prSubCommands = {
8733
9099
  },
8734
9100
  async run({ args }) {
8735
9101
  const out = createCliOutput(args.json === true);
8736
- const result = await runPrMerge(cliFetcher(), {
8737
- cwd: process.cwd(),
8738
- number: args.number,
8739
- method: args.method ?? void 0,
8740
- deleteBranch: args["delete-branch"] === true,
8741
- ignoreChecks: args["ignore-checks"] === true,
9102
+ const conn = requireForgejoTargetFromRepo(process.cwd(), args.repo ?? void 0, "pr:retarget is Forgejo-only -- use gh pr edit <n> --base <branch> on GitHub.");
9103
+ const result = await runPrRetarget(forgejoPrEditHost(cliFetcher(), conn), {
9104
+ number: parsePrNumber(args.number),
9105
+ base: args.base,
8742
9106
  dryRun: args["dry-run"] === true,
8743
- repo: args.repo ?? void 0
9107
+ recheckDelayMs: MERGEABLE_RECHECK_DELAY_MS,
9108
+ sleep
8744
9109
  });
8745
9110
  out.json(result);
8746
- out.text(renderPrMerge(result));
9111
+ out.text(renderPrRetarget(result));
8747
9112
  }
8748
9113
  }),
8749
9114
  "pr:update": defineCommand({
@@ -8766,7 +9131,7 @@ const prSubCommands = {
8766
9131
  },
8767
9132
  base: {
8768
9133
  type: "string",
8769
- description: "Base branch to diff against (default: the repo's); never repoints the PR"
9134
+ description: "Base branch to diff against (default: the repo's); never repoints the PR (pr:retarget does)"
8770
9135
  },
8771
9136
  "allow-unlink": {
8772
9137
  type: "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/forgejo-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "bst-fj: Forgejo API commands without the rest of bst",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "devDependencies": {
35
35
  "@bensandee/cli-kit": "0.0.0",
36
36
  "@bensandee/forgejo": "0.7.0",
37
- "@types/node": "24.13.5",
37
+ "@types/node": "24.19.1",
38
38
  "tsdown": "0.23.0"
39
39
  },
40
40
  "scripts": {