@bensandee/forgejo-cli 0.3.1 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/dist/bin.mjs +400 -41
  3. package/package.json +1 -1
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.1",
248
+ version: "0.4.0",
249
249
  description: "bst-fj: Forgejo API commands without the rest of bst",
250
250
  license: "MIT",
251
251
  repository: {
@@ -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
@@ -4923,7 +4952,7 @@ async function refusalMessage(res) {
4923
4952
  try {
4924
4953
  const parsed = ErrorBodySchema.safeParse(JSON.parse(text));
4925
4954
  if (parsed.success) return parsed.data.message;
4926
- } catch {}
4955
+ } catch (_error) {}
4927
4956
  return text;
4928
4957
  }
4929
4958
  /** Delete one branch. The name is encoded per segment so `feat/a/b` keeps its slashes. */
@@ -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`, {
@@ -5472,6 +5492,63 @@ function normalizePrDetail(data) {
5472
5492
  async function getForgejoPrDetail(executor, conn, prNumber) {
5473
5493
  return normalizePrDetail(await readPrField(executor, conn, prNumber, PrDetailSchema, "detail"));
5474
5494
  }
5495
+ const PrEditStateSchema = z.object({
5496
+ state: z.string(),
5497
+ merged: z.boolean(),
5498
+ draft: z.boolean(),
5499
+ title: z.string(),
5500
+ head: z.object({ ref: z.string() }),
5501
+ base: z.object({ ref: z.string() }),
5502
+ mergeable: z.boolean().nullish(),
5503
+ html_url: z.string().optional()
5504
+ });
5505
+ /**
5506
+ * Read a pull request's edit state in one `/pulls/<n>` GET -- the read both editors run before
5507
+ * they write, and the one they run again to confirm the write took.
5508
+ *
5509
+ * `url` prefers the forge's own `html_url` because an instance that serves it knows its real web
5510
+ * route (a reverse-proxied path included), and falls back to {@link prWebUrl} when it does not.
5511
+ * `mergeable` stays tri-state for {@link PrDetail}'s reason: absent is "still computing", not
5512
+ * "conflicted".
5513
+ */
5514
+ async function getPrEditState(executor, conn, prNumber) {
5515
+ const parsed = await readPrField(executor, conn, prNumber, PrEditStateSchema, "edit state");
5516
+ return {
5517
+ state: parsed.state,
5518
+ merged: parsed.merged,
5519
+ draft: parsed.draft,
5520
+ title: parsed.title,
5521
+ head: parsed.head.ref,
5522
+ base: parsed.base.ref,
5523
+ mergeable: parsed.mergeable ?? null,
5524
+ url: parsed.html_url ?? prWebUrl(conn, prNumber)
5525
+ };
5526
+ }
5527
+ /**
5528
+ * Repoint a pull request at a new base branch with a PATCH carrying `base` and nothing else.
5529
+ *
5530
+ * Its own function rather than another field on {@link UpdatePrOptions}: that interface is the
5531
+ * title-and-body pair whose whole contract is "an omitted key is left alone", and widening it to
5532
+ * carry a branch name would let a caller that only means to fix a title move a pull request's
5533
+ * base by accident. The caller confirms the write by re-reading -- Forgejo accepts an unknown
5534
+ * `EditPullRequestOption` field silently, so a PATCH that answered 200 is not proof the base
5535
+ * moved.
5536
+ */
5537
+ async function retargetPr(executor, conn, prNumber, base) {
5538
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
5539
+ const res = await executor.fetch(url, {
5540
+ method: "PATCH",
5541
+ headers: {
5542
+ Authorization: `token ${conn.token}`,
5543
+ "Content-Type": "application/json"
5544
+ },
5545
+ body: JSON.stringify({ base })
5546
+ }, { idempotent: true });
5547
+ if (!res.ok) await throwForgejoFailure(res, {
5548
+ verb: "retarget PR",
5549
+ resource: `#${String(prNumber)}`
5550
+ });
5551
+ }
5475
5552
  const PrContentSchema = z.object({
5476
5553
  title: z.string(),
5477
5554
  body: z.string().nullish()
@@ -6212,6 +6289,12 @@ function resolveTargetPath(outDir, name, taken) {
6212
6289
  * login page with a 200 status. Verifying the length is what stops that login page
6213
6290
  * from landing on disk named `plan.md`, where a reader would then act on it.
6214
6291
  * Nothing is written until the bytes check out.
6292
+ *
6293
+ * Exported because a caller that wants the bytes in memory rather than on disk -- one
6294
+ * collecting an issue's images for a container -- needs exactly this check and has no way
6295
+ * to write it without the same reasoning about the login page. It lives here, beside its
6296
+ * only other caller, rather than in a new module: `packages/forgejo/src` sits at its
6297
+ * crowding ceiling.
6215
6298
  */
6216
6299
  async function downloadAttachment(fetcher, token, attachment) {
6217
6300
  const res = await fetcher.fetch(attachment.downloadUrl, { headers: { Authorization: `token ${token}` } }, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
@@ -6235,8 +6318,7 @@ async function downloadIssueAttachments(resolve, request) {
6235
6318
  const files = request.files ?? diskFiles;
6236
6319
  const issue = await getForgejoIssue(fetcher, conn, number);
6237
6320
  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()];
6321
+ const attachments = [...issue.attachments, ...comments.flatMap((comment) => comment.attachments)];
6240
6322
  const outDir = request.outDir;
6241
6323
  const taken = /* @__PURE__ */ new Set();
6242
6324
  const downloaded = [];
@@ -8099,6 +8181,20 @@ function forgejoPrHost(fetcher, conn) {
8099
8181
  }
8100
8182
  };
8101
8183
  }
8184
+ /**
8185
+ * The Forgejo {@link PrEditHost}, over the API calls in `pr.ts` and `repo.ts`. Its `ready` is a
8186
+ * title-only PATCH and never a body write: the body is the author's text, and the draft flag has
8187
+ * no PATCH field of its own on this forge.
8188
+ */
8189
+ function forgejoPrEditHost(fetcher, conn) {
8190
+ return {
8191
+ wipTitles: true,
8192
+ readLive: (number) => getPrEditState(fetcher, conn, number),
8193
+ branchExists: (name) => branchExists(fetcher, conn, name),
8194
+ setBase: (number, base) => retargetPr(fetcher, conn, number, base),
8195
+ ready: (number, write) => updatePr(fetcher, conn, number, { title: write.title })
8196
+ };
8197
+ }
8102
8198
  //#endregion
8103
8199
  //#region src/git.ts
8104
8200
  /** Exit codes reported when this module ends a child itself, following the shell's convention. */
@@ -8331,16 +8427,22 @@ async function listPrFiles(executor, conn, prNumber, options) {
8331
8427
  };
8332
8428
  }
8333
8429
  //#endregion
8334
- //#region src/pr-files.ts
8430
+ //#region ../forgejo/src/pr-number.ts
8335
8431
  /**
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.
8432
+ * The one parser for a pull request number given as text, shared by every bin's positional so a
8433
+ * malformed number refuses the same way whether a person or a script typed it.
8338
8434
  */
8339
8435
  function parsePrNumber(raw) {
8340
8436
  const value = Number(raw);
8341
8437
  if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1) throw new FatalError(`Invalid pull request number "${raw}" (expected a positive integer).`);
8342
8438
  return value;
8343
8439
  }
8440
+ //#endregion
8441
+ //#region src/pr-files.ts
8442
+ /**
8443
+ * `bst-fj pr:files`: the files a pull request changes, over `@bensandee/forgejo/pr-files`.
8444
+ * Forgejo-only; `gh pr diff --name-only` already serves GitHub.
8445
+ */
8344
8446
  /** List the files a pull request changes. */
8345
8447
  function runPrFiles(fetcher, options) {
8346
8448
  const number = parsePrNumber(options.number);
@@ -8685,16 +8787,237 @@ function renderPrMerge(result) {
8685
8787
  return result.merged ? `Merged ${what}.` : `Dry run: would merge ${what}.`;
8686
8788
  }
8687
8789
  //#endregion
8790
+ //#region src/commands/pr-merge.ts
8791
+ const prMergeCommand = defineCommand({
8792
+ meta: {
8793
+ name: "pr:merge",
8794
+ 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)"
8795
+ },
8796
+ args: {
8797
+ number: {
8798
+ type: "positional",
8799
+ required: true,
8800
+ description: "Pull request number"
8801
+ },
8802
+ method: {
8803
+ type: "string",
8804
+ description: "Merge method: merge, rebase or squash (default: merge)"
8805
+ },
8806
+ "delete-branch": {
8807
+ type: "boolean",
8808
+ description: "Delete the head branch after the merge"
8809
+ },
8810
+ "ignore-checks": {
8811
+ type: "boolean",
8812
+ description: "Merge even when the head's combined status is not green"
8813
+ },
8814
+ "dry-run": {
8815
+ type: "boolean",
8816
+ description: "Run every check and report, but do not merge"
8817
+ },
8818
+ repo: {
8819
+ type: "string",
8820
+ description: "Repository URL (default: this repo)"
8821
+ },
8822
+ json: {
8823
+ type: "boolean",
8824
+ 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."
8825
+ },
8826
+ verbose: {
8827
+ type: "boolean",
8828
+ description: "Emit detailed debug output"
8829
+ }
8830
+ },
8831
+ async run({ args }) {
8832
+ const out = createCliOutput(args.json === true);
8833
+ const result = await runPrMerge(cliFetcher(), {
8834
+ cwd: process.cwd(),
8835
+ number: args.number,
8836
+ method: args.method ?? void 0,
8837
+ deleteBranch: args["delete-branch"] === true,
8838
+ ignoreChecks: args["ignore-checks"] === true,
8839
+ dryRun: args["dry-run"] === true,
8840
+ repo: args.repo ?? void 0
8841
+ });
8842
+ out.json(result);
8843
+ out.text(renderPrMerge(result));
8844
+ }
8845
+ });
8846
+ //#endregion
8847
+ //#region ../forgejo/src/pr-edit.ts
8848
+ /**
8849
+ * The two pull-request editing engines, forge-generic: what a base change and a ready write check,
8850
+ * send, and confirm. Each one drives a {@link PrEditHost}, so the sequence is written once and only
8851
+ * the writes differ per forge -- the refusals and the confirming re-read are the parts a person
8852
+ * relies on, and they must not be re-derived on each side of the seam.
8853
+ *
8854
+ * Engines and renderers share this module because every importer of either is the same command: a
8855
+ * renderer kept apart would be a second module for the same four callers.
8856
+ */
8857
+ /** The wait before the one mergeability re-read after a retarget; both bins pass it. */
8858
+ const MERGEABLE_RECHECK_DELAY_MS = 2e3;
8859
+ /** What both forges call an editable pull request. One predicate, so the two engines cannot
8860
+ * disagree about it and a caller cannot read "open" as its own looser thing. */
8861
+ function isOpen(live) {
8862
+ return live.state === "open" && live.merged === false;
8863
+ }
8864
+ function notOpenMessage(prNumber, live) {
8865
+ const what = live.merged ? "merged" : live.state;
8866
+ 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.`;
8867
+ }
8868
+ /**
8869
+ * Point an open pull request at a new base branch, then prove the forge took it.
8870
+ *
8871
+ * The re-read is the whole point of the command: a forge that does not implement the base field
8872
+ * answers the PATCH with 200 and changes nothing, so a report built from the request would claim a
8873
+ * move that never happened. `mergeable` rides along as advice only and never decides the outcome --
8874
+ * it is tri-state on both forges and a pull request whose base just moved is being recomputed by
8875
+ * definition -- so a `false` earns exactly one more read after a wait, and `null` and `true` are
8876
+ * taken as final.
8877
+ */
8878
+ async function runPrRetarget(host, options) {
8879
+ const live = await host.readLive(options.number);
8880
+ if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
8881
+ 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.`);
8882
+ if (options.base === live.base) return {
8883
+ action: "unchanged",
8884
+ number: options.number,
8885
+ url: live.url,
8886
+ previousBase: live.base,
8887
+ base: live.base,
8888
+ head: live.head,
8889
+ mergeable: live.mergeable,
8890
+ dryRun: options.dryRun
8891
+ };
8892
+ 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.`);
8893
+ if (options.dryRun) return {
8894
+ action: "would-retarget",
8895
+ number: options.number,
8896
+ url: live.url,
8897
+ previousBase: live.base,
8898
+ base: options.base,
8899
+ head: live.head,
8900
+ mergeable: live.mergeable,
8901
+ dryRun: true
8902
+ };
8903
+ await host.setBase(options.number, options.base);
8904
+ const recheck = await host.readLive(options.number);
8905
+ 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.`);
8906
+ let mergeable = recheck.mergeable;
8907
+ if (mergeable === false) {
8908
+ await options.sleep(options.recheckDelayMs);
8909
+ mergeable = (await host.readLive(options.number)).mergeable;
8910
+ }
8911
+ return {
8912
+ action: "retargeted",
8913
+ number: options.number,
8914
+ url: recheck.url,
8915
+ previousBase: live.base,
8916
+ base: options.base,
8917
+ head: live.head,
8918
+ mergeable,
8919
+ dryRun: false
8920
+ };
8921
+ }
8922
+ /**
8923
+ * Mark an open pull request ready, dropping its WIP marker or writing the title given.
8924
+ *
8925
+ * The draft is the forge's flag, never the title prefix: a prefix is how a title reads and a draft
8926
+ * is what the forge refuses to merge, and the two disagree on both forges. So the title is the
8927
+ * mechanism (it is the only lever Forgejo's PATCH has) and the flag is the question of whether
8928
+ * there is anything to do -- which is also why a successful write is re-read rather than trusted.
8929
+ * A draft whose title carries no marker this side recognises is refused rather than readied by
8930
+ * guessing at one: on that forge only the title can clear the flag, and an invented title is a
8931
+ * rewrite of someone's headline.
8932
+ */
8933
+ async function runPrReady(host, options) {
8934
+ const live = await host.readLive(options.number);
8935
+ if (!isOpen(live)) throw new FatalError(notOpenMessage(options.number, live));
8936
+ const given = options.title;
8937
+ if (given !== void 0) {
8938
+ 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.`);
8939
+ if (hasWipPrefix(given)) {
8940
+ 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";
8941
+ throw new FatalError(`--title ${JSON.stringify(given)} carries a WIP marker, ${reason} -- pass the title you want it to read without one.`);
8942
+ }
8943
+ }
8944
+ const finalTitle = given ?? stripWipPrefix(live.title);
8945
+ 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.`);
8946
+ 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.`);
8947
+ const titleChanged = finalTitle !== live.title;
8948
+ if (!live.draft && !titleChanged) return {
8949
+ action: "already-ready",
8950
+ number: options.number,
8951
+ url: live.url,
8952
+ previousTitle: live.title,
8953
+ title: live.title,
8954
+ draft: live.draft,
8955
+ dryRun: options.dryRun
8956
+ };
8957
+ if (options.dryRun) return {
8958
+ action: live.draft ? "would-ready" : "would-retitle",
8959
+ number: options.number,
8960
+ url: live.url,
8961
+ previousTitle: live.title,
8962
+ title: finalTitle,
8963
+ draft: live.draft,
8964
+ dryRun: true
8965
+ };
8966
+ await host.ready(options.number, {
8967
+ draft: live.draft,
8968
+ title: finalTitle,
8969
+ titleChanged
8970
+ });
8971
+ const recheck = await host.readLive(options.number);
8972
+ 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.`);
8973
+ return {
8974
+ action: live.draft ? "readied" : "retitled",
8975
+ number: options.number,
8976
+ url: recheck.url,
8977
+ previousTitle: live.title,
8978
+ title: recheck.title,
8979
+ draft: recheck.draft,
8980
+ dryRun: false
8981
+ };
8982
+ }
8983
+ 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.";
8984
+ 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.";
8985
+ function mergeabilityLine(mergeable) {
8986
+ if (mergeable === true) return "Mergeability: mergeable.";
8987
+ if (mergeable === false) return "Mergeability: not mergeable -- advice, not a verdict.";
8988
+ return "Mergeability: unknown -- the forge has not computed it.";
8989
+ }
8990
+ /** What happened to the base, and the mergeability reading as advice. */
8991
+ function renderPrRetarget(result) {
8992
+ const number = `#${String(result.number)}`;
8993
+ const lines = [];
8994
+ if (result.action === "retargeted") lines.push(`Pull request ${number} now targets "${result.base}" (was "${result.previousBase}").`);
8995
+ else if (result.action === "unchanged") lines.push(`Pull request ${number} already targets "${result.base}".`);
8996
+ else lines.push(`Dry run: pull request ${number} would move from "${result.previousBase}" to "${result.base}".`);
8997
+ lines.push(mergeabilityLine(result.mergeable));
8998
+ return lines.join("\n");
8999
+ }
9000
+ /** What happened to the draft state and the title. */
9001
+ function renderPrReady(result) {
9002
+ const number = `#${String(result.number)}`;
9003
+ if (result.action === "readied") return `Pull request ${number} is no longer a draft, reading as "${result.title}".`;
9004
+ if (result.action === "retitled") return `Pull request ${number} now reads "${result.title}".`;
9005
+ if (result.action === "already-ready") return `Pull request ${number} is already ready and reads "${result.title}".`;
9006
+ if (result.action === "would-ready") return `Dry run: pull request ${number} would stop being a draft, reading "${result.title}".`;
9007
+ return `Dry run: pull request ${number} would read "${result.title}".`;
9008
+ }
9009
+ //#endregion
8688
9010
  //#region src/registry-pr.ts
8689
9011
  const prSubCommands = {
8690
9012
  "pr:checks": prChecksCommand,
8691
9013
  "pr:create": prCreateCommand,
8692
9014
  "pr:files": prFilesCommand,
8693
9015
  "pr:list": prListCommand,
8694
- "pr:merge": defineCommand({
9016
+ "pr:merge": prMergeCommand,
9017
+ "pr:ready": defineCommand({
8695
9018
  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)"
9019
+ name: "pr:ready",
9020
+ 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
9021
  },
8699
9022
  args: {
8700
9023
  number: {
@@ -8702,29 +9025,66 @@ const prSubCommands = {
8702
9025
  required: true,
8703
9026
  description: "Pull request number"
8704
9027
  },
8705
- method: {
9028
+ title: {
9029
+ type: "string",
9030
+ description: "Write this title instead of stripping the WIP prefix from the current one"
9031
+ },
9032
+ repo: {
8706
9033
  type: "string",
8707
- description: "Merge method: merge, rebase or squash (default: merge)"
9034
+ description: "Repository URL (default: this repo)"
8708
9035
  },
8709
- "delete-branch": {
9036
+ "dry-run": {
8710
9037
  type: "boolean",
8711
- description: "Delete the head branch after the merge"
9038
+ description: "Run every read and report, but do not write"
8712
9039
  },
8713
- "ignore-checks": {
9040
+ json: {
8714
9041
  type: "boolean",
8715
- description: "Merge even when the head's combined status is not green"
9042
+ description: PR_READY_JSON_HELP
8716
9043
  },
8717
- "dry-run": {
9044
+ verbose: {
8718
9045
  type: "boolean",
8719
- description: "Run every check and report, but do not merge"
9046
+ description: "Emit detailed debug output"
9047
+ }
9048
+ },
9049
+ async run({ args }) {
9050
+ const out = createCliOutput(args.json === true);
9051
+ const conn = requireForgejoTargetFromRepo(process.cwd(), args.repo ?? void 0, "pr:ready is Forgejo-only -- use gh pr ready <n> on GitHub.");
9052
+ const result = await runPrReady(forgejoPrEditHost(cliFetcher(), conn), {
9053
+ number: parsePrNumber(args.number),
9054
+ title: args.title ?? void 0,
9055
+ dryRun: args["dry-run"] === true
9056
+ });
9057
+ out.json(result);
9058
+ out.text(renderPrReady(result));
9059
+ }
9060
+ }),
9061
+ "pr:retarget": defineCommand({
9062
+ meta: {
9063
+ name: "pr:retarget",
9064
+ 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)"
9065
+ },
9066
+ args: {
9067
+ number: {
9068
+ type: "positional",
9069
+ required: true,
9070
+ description: "Pull request number"
9071
+ },
9072
+ base: {
9073
+ type: "string",
9074
+ required: true,
9075
+ description: "The branch to retarget the pull request onto"
8720
9076
  },
8721
9077
  repo: {
8722
9078
  type: "string",
8723
9079
  description: "Repository URL (default: this repo)"
8724
9080
  },
9081
+ "dry-run": {
9082
+ type: "boolean",
9083
+ description: "Run every read and report, but do not write"
9084
+ },
8725
9085
  json: {
8726
9086
  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."
9087
+ description: PR_RETARGET_JSON_HELP
8728
9088
  },
8729
9089
  verbose: {
8730
9090
  type: "boolean",
@@ -8733,17 +9093,16 @@ const prSubCommands = {
8733
9093
  },
8734
9094
  async run({ args }) {
8735
9095
  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,
9096
+ const conn = requireForgejoTargetFromRepo(process.cwd(), args.repo ?? void 0, "pr:retarget is Forgejo-only -- use gh pr edit <n> --base <branch> on GitHub.");
9097
+ const result = await runPrRetarget(forgejoPrEditHost(cliFetcher(), conn), {
9098
+ number: parsePrNumber(args.number),
9099
+ base: args.base,
8742
9100
  dryRun: args["dry-run"] === true,
8743
- repo: args.repo ?? void 0
9101
+ recheckDelayMs: MERGEABLE_RECHECK_DELAY_MS,
9102
+ sleep
8744
9103
  });
8745
9104
  out.json(result);
8746
- out.text(renderPrMerge(result));
9105
+ out.text(renderPrRetarget(result));
8747
9106
  }
8748
9107
  }),
8749
9108
  "pr:update": defineCommand({
@@ -8766,7 +9125,7 @@ const prSubCommands = {
8766
9125
  },
8767
9126
  base: {
8768
9127
  type: "string",
8769
- description: "Base branch to diff against (default: the repo's); never repoints the PR"
9128
+ description: "Base branch to diff against (default: the repo's); never repoints the PR (pr:retarget does)"
8770
9129
  },
8771
9130
  "allow-unlink": {
8772
9131
  type: "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/forgejo-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "bst-fj: Forgejo API commands without the rest of bst",
5
5
  "license": "MIT",
6
6
  "repository": {