@bensandee/forgejo-cli 0.3.2 → 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.
- package/README.md +4 -2
- package/dist/bin.mjs +399 -40
- 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:
|
|
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:
|
|
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.
|
|
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
|
|
@@ -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
|
|
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-
|
|
8430
|
+
//#region ../forgejo/src/pr-number.ts
|
|
8335
8431
|
/**
|
|
8336
|
-
*
|
|
8337
|
-
*
|
|
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":
|
|
9016
|
+
"pr:merge": prMergeCommand,
|
|
9017
|
+
"pr:ready": defineCommand({
|
|
8695
9018
|
meta: {
|
|
8696
|
-
name: "pr:
|
|
8697
|
-
description: "
|
|
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
|
-
|
|
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: "
|
|
9034
|
+
description: "Repository URL (default: this repo)"
|
|
8708
9035
|
},
|
|
8709
|
-
"
|
|
9036
|
+
"dry-run": {
|
|
8710
9037
|
type: "boolean",
|
|
8711
|
-
description: "
|
|
9038
|
+
description: "Run every read and report, but do not write"
|
|
8712
9039
|
},
|
|
8713
|
-
|
|
9040
|
+
json: {
|
|
8714
9041
|
type: "boolean",
|
|
8715
|
-
description:
|
|
9042
|
+
description: PR_READY_JSON_HELP
|
|
8716
9043
|
},
|
|
8717
|
-
|
|
9044
|
+
verbose: {
|
|
8718
9045
|
type: "boolean",
|
|
8719
|
-
description: "
|
|
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:
|
|
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
|
|
8737
|
-
|
|
8738
|
-
number: args.number,
|
|
8739
|
-
|
|
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
|
-
|
|
9101
|
+
recheckDelayMs: MERGEABLE_RECHECK_DELAY_MS,
|
|
9102
|
+
sleep
|
|
8744
9103
|
});
|
|
8745
9104
|
out.json(result);
|
|
8746
|
-
out.text(
|
|
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",
|