Skip to content

Commit 1e968ea

Browse files
authored
Merge pull request #39 from Freudator86/feat/pull-request-url-addressing
feat: address a pull request by its URL as well as its number
2 parents fd2bb8b + 88fe764 commit 1e968ea

7 files changed

Lines changed: 361 additions & 54 deletions

File tree

‎docs/contract.md‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ forgejo-axi repo view --repo OWNER/REPO [connection flags]
1313
forgejo-axi api METHOD PATH [--data JSON] [--paginate [--limit N|--full]] [connection flags]
1414
forgejo-axi pr find --repo OWNER/REPO --head BRANCH [--base BRANCH] [--state STATE]
1515
forgejo-axi pr list --repo OWNER/REPO [--state STATE] [--limit N|--full] [--fields LIST|all]
16-
forgejo-axi pr view --repo OWNER/REPO NUMBER [--full] [--fields LIST|all]
17-
forgejo-axi pr history [overview|list|detail|soft-delete] --repo OWNER/REPO NUMBER [--comment-id ID] [--history-id ID] [--raw] [--yes]
18-
forgejo-axi pr reviews --repo OWNER/REPO NUMBER [--limit N|--full]
19-
forgejo-axi pr diff --repo OWNER/REPO NUMBER [--full]
16+
forgejo-axi pr view [--repo OWNER/REPO] NUMBER|URL [--full] [--fields LIST|all]
17+
forgejo-axi pr history [overview|list|detail|soft-delete] [--repo OWNER/REPO] NUMBER|URL [--comment-id ID] [--history-id ID] [--raw] [--yes]
18+
forgejo-axi pr reviews [--repo OWNER/REPO] NUMBER|URL [--limit N|--full]
19+
forgejo-axi pr diff [--repo OWNER/REPO] NUMBER|URL [--full]
2020
forgejo-axi pr create --repo OWNER/REPO --title TITLE --head BRANCH --base BRANCH [--body BODY | --body-file PATH|-] [--draft]
21-
forgejo-axi pr update --repo OWNER/REPO NUMBER [--title TITLE] [--body BODY | --body-file PATH|-] [--base BRANCH] [--state open|closed]
22-
forgejo-axi pr checks --repo OWNER/REPO NUMBER
23-
forgejo-axi pr mergeability --repo OWNER/REPO NUMBER
24-
forgejo-axi pr merge --repo OWNER/REPO NUMBER --expected-head SHA [--method merge|squash|rebase]
25-
forgejo-axi pr merged --repo OWNER/REPO NUMBER
21+
forgejo-axi pr update [--repo OWNER/REPO] NUMBER|URL [--title TITLE] [--body BODY | --body-file PATH|-] [--base BRANCH] [--state open|closed]
22+
forgejo-axi pr checks [--repo OWNER/REPO] NUMBER|URL
23+
forgejo-axi pr mergeability [--repo OWNER/REPO] NUMBER|URL
24+
forgejo-axi pr merge [--repo OWNER/REPO] NUMBER|URL --expected-head SHA [--method merge|squash|rebase]
25+
forgejo-axi pr merged [--repo OWNER/REPO] NUMBER|URL
2626
forgejo-axi label list --repo OWNER/REPO [--limit N|--full]
2727
forgejo-axi label create --repo OWNER/REPO NAME [--color HEX] [--description TEXT]
2828
forgejo-axi label edit --repo OWNER/REPO NAME [--name NEW] [--color HEX] [--description TEXT]
@@ -51,6 +51,8 @@ separate value beginning with `-` remains reserved for flags, except the exact
5151

5252
For `pr create` and `pr update`, `--body` and `--body-file` are mutually exclusive. `--body-file PATH` reads a UTF-8 file and `--body-file -` reads stdin; body-file content is forwarded verbatim. Input that cannot be read, or whose bytes are not valid UTF-8, is refused with `BODY_FILE_ERROR` (exit `2`) before any request is made, because the invocation named an unusable body source rather than the host failing. No other command accepts `--body-file`: `issue create`, `issue edit`, and `issue comment` take `--body` only.
5353

54+
A pull request `URL` supplies the repository and number for every command that accepts `NUMBER|URL`; it never changes the configured Forgejo base URL or where credentials are sent. The URL must use HTTP(S), contain no credentials, and carry an `OWNER/REPO/pulls/NUMBER` path, including beneath a path prefix or the API route. An explicit `--repo` may accompany a URL only when it names the same repository; a URL overrides `FORGEJO_REPOSITORY`.
55+
5456
`--fields LIST|all` on `pr view`, `issue view`, and `run view` selects fields only from the command's primary `pull_request`, `issue`, or `run` object. Sibling comments, jobs, metadata, and hints are unchanged. Fields are emitted in requested order; a requested optional field that the host did not supply is omitted rather than fabricated as `null`. `all` selects every field that detail object can expose.
5557

5658
Connection flags are `--base-url URL`, `--token-env NAME`, `--timeout-ms N`, `--ca-file PATH`, and `--json`. Environment defaults are `FORGEJO_BASE_URL`, `FORGEJO_REPOSITORY`, `FORGEJO_TIMEOUT_MS`, and `FORGEJO_CA_FILE`. `--ca-file`/`FORGEJO_CA_FILE` supplies a replacement CA trust bundle, matching Node's TLS `ca` behavior; it does not append to the platform trust store.

‎skills/forgejo-axi/SKILL.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ Commands:
225225
merge Merge only the expected head and return merged proof
226226
merged Return merged-state proof
227227
228+
Wherever a command takes NUMBER it also accepts the pull request's URL, which
229+
carries the repository with it and makes --repo unnecessary. The request still
230+
goes to the configured base URL; the URL's host is never used to route it.
231+
228232
Run `forgejo-axi pr <command> --help` for flags and examples.
229233
```
230234

@@ -262,7 +266,7 @@ Examples:
262266
forgejo-axi pr view — show canonical pull request identity and body
263267
264268
Usage:
265-
forgejo-axi pr view --repo OWNER/REPO NUMBER [--full] [--fields LIST|all] [connection flags]
269+
forgejo-axi pr view [--repo OWNER/REPO] NUMBER|URL [--full] [--fields LIST|all] [connection flags]
266270
267271
Flags:
268272
--full Display the complete pull request body instead of its preview
@@ -272,7 +276,7 @@ A requested field the host does not supply is omitted, never emitted as null.
272276
273277
Examples:
274278
forgejo-axi pr view --repo owner/repo 42 --full
275-
forgejo-axi pr view --repo owner/repo 42 --fields state,head_sha
279+
forgejo-axi pr view https://forgejo.example/owner/repo/pulls/42 --fields state,head_sha
276280
```
277281

278282
#### pr history
@@ -281,7 +285,7 @@ Examples:
281285
forgejo-axi pr history — inspect or soft-delete pull request content history
282286
283287
Usage:
284-
forgejo-axi pr history [overview|list|detail|soft-delete] --repo OWNER/REPO NUMBER [--comment-id ID] [--history-id ID] [--raw] [--yes] [connection flags]
288+
forgejo-axi pr history [overview|list|detail|soft-delete] [--repo OWNER/REPO] NUMBER|URL [--comment-id ID] [--history-id ID] [--raw] [--yes] [connection flags]
285289
286290
Commands:
287291
overview Show edit counts for the body and comments
@@ -308,7 +312,7 @@ Examples:
308312
forgejo-axi pr reviews — list reviews with their inline comments
309313
310314
Usage:
311-
forgejo-axi pr reviews --repo OWNER/REPO NUMBER [--limit N|--full] [connection flags]
315+
forgejo-axi pr reviews [--repo OWNER/REPO] NUMBER|URL [--limit N|--full] [connection flags]
312316
313317
Flags:
314318
--limit N Display this many reviews instead of the default 30
@@ -324,7 +328,7 @@ Example:
324328
forgejo-axi pr diff — print the unified diff
325329
326330
Usage:
327-
forgejo-axi pr diff --repo OWNER/REPO NUMBER [--full] [connection flags]
331+
forgejo-axi pr diff [--repo OWNER/REPO] NUMBER|URL [--full] [connection flags]
328332
329333
Flags:
330334
--full Print every line instead of the first 30
@@ -360,7 +364,7 @@ Examples:
360364
forgejo-axi pr update — idempotently update a pull request
361365
362366
Usage:
363-
forgejo-axi pr update --repo OWNER/REPO NUMBER [--title TITLE] [--body BODY | --body-file PATH|-] [--base BRANCH] [--state open|closed] [connection flags]
367+
forgejo-axi pr update [--repo OWNER/REPO] NUMBER|URL [--title TITLE] [--body BODY | --body-file PATH|-] [--base BRANCH] [--state open|closed] [connection flags]
364368
365369
Flags:
366370
--body BODY Pull request body text
@@ -380,7 +384,7 @@ Examples:
380384
forgejo-axi pr checks — normalize statuses and required contexts
381385
382386
Usage:
383-
forgejo-axi pr checks --repo OWNER/REPO NUMBER [connection flags]
387+
forgejo-axi pr checks [--repo OWNER/REPO] NUMBER|URL [connection flags]
384388
385389
Example:
386390
forgejo-axi pr checks --repo owner/repo 42 --json
@@ -392,7 +396,7 @@ Example:
392396
forgejo-axi pr mergeability — evaluate mergeability and required checks
393397
394398
Usage:
395-
forgejo-axi pr mergeability --repo OWNER/REPO NUMBER [connection flags]
399+
forgejo-axi pr mergeability [--repo OWNER/REPO] NUMBER|URL [connection flags]
396400
397401
Example:
398402
forgejo-axi pr mergeability --repo owner/repo 42
@@ -404,7 +408,7 @@ Example:
404408
forgejo-axi pr merge — expected-head merge with merged-state proof
405409
406410
Usage:
407-
forgejo-axi pr merge --repo OWNER/REPO NUMBER --expected-head SHA [--method merge|squash|rebase] [connection flags]
411+
forgejo-axi pr merge [--repo OWNER/REPO] NUMBER|URL --expected-head SHA [--method merge|squash|rebase] [connection flags]
408412
409413
Example:
410414
forgejo-axi pr merge --repo owner/repo 42 --expected-head abc123 --method squash
@@ -416,7 +420,7 @@ Example:
416420
forgejo-axi pr merged — return merged-state proof
417421
418422
Usage:
419-
forgejo-axi pr merged --repo OWNER/REPO NUMBER [connection flags]
423+
forgejo-axi pr merged [--repo OWNER/REPO] NUMBER|URL [connection flags]
420424
421425
Example:
422426
forgejo-axi pr merged --repo owner/repo 42

‎src/cli.ts‎

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
normalizeLabelColor,
2020
pageInfo,
2121
parseIssueNumber,
22-
parsePullNumber,
22+
parsePullTarget,
2323
parseRepository,
2424
parseRunId,
2525
type IssueIdentity,
@@ -379,10 +379,7 @@ async function pullRead(
379379
}),
380380
`pr ${command}`,
381381
);
382-
const number = parsePullNumber(
383-
requireOnePositional(parsed, 'pull request number'),
384-
);
385-
const repo = resolveRepo(parsed, env);
382+
const { repo, number } = resolvePullTarget(parsed, env);
386383
const service = await serviceFor(parsed, env);
387384
if (command === 'view') {
388385
const fields = chooseDetailFields(
@@ -422,14 +419,11 @@ async function pullReviews(
422419
}),
423420
'pr reviews',
424421
);
425-
const number = parsePullNumber(
426-
requireOnePositional(parsed, 'pull request number'),
427-
);
422+
const { repo, number } = resolvePullTarget(parsed, env);
428423
const full = boolFlag(parsed, '--full');
429424
const requestedLimit = stringFlag(parsed, '--limit');
430425
const json = boolFlag(parsed, '--json');
431426
rejectDisplayFlagConflicts(full, requestedLimit, json);
432-
const repo = resolveRepo(parsed, env);
433427
const service = await serviceFor(parsed, env);
434428
const page = await service.listReviews(repo, number, full);
435429
const showAll = full || json;
@@ -448,10 +442,7 @@ async function pullDiff(
448442
withFlags({ '--repo': 'value', '--full': 'boolean' }),
449443
'pr diff',
450444
);
451-
const number = parsePullNumber(
452-
requireOnePositional(parsed, 'pull request number'),
453-
);
454-
const repo = resolveRepo(parsed, env);
445+
const { repo, number } = resolvePullTarget(parsed, env);
455446
const service = await serviceFor(parsed, env);
456447
const diff = await service.diffPull(repo, number);
457448
const showAll = boolFlag(parsed, '--full') || boolFlag(parsed, '--json');
@@ -532,10 +523,7 @@ async function pullUpdate(
532523
}),
533524
'pr update',
534525
);
535-
const number = parsePullNumber(
536-
requireOnePositional(parsed, 'pull request number'),
537-
);
538-
const repo = resolveRepo(parsed, env);
526+
const { repo, number } = resolvePullTarget(parsed, env);
539527
const state = stringFlag(parsed, '--state');
540528
if (state !== undefined) stateFlag(state, false);
541529
const title = stringFlag(parsed, '--title');
@@ -611,10 +599,7 @@ async function pullMerge(
611599
}),
612600
'pr merge',
613601
);
614-
const number = parsePullNumber(
615-
requireOnePositional(parsed, 'pull request number'),
616-
);
617-
const repo = resolveRepo(parsed, env);
602+
const { repo, number } = resolvePullTarget(parsed, env);
618603
const expectedHead = requireFlag(parsed, '--expected-head');
619604
const rawMethod = stringFlag(parsed, '--method') ?? 'merge';
620605
if (!['merge', 'squash', 'rebase'].includes(rawMethod)) {
@@ -887,11 +872,14 @@ async function contentHistoryRead(
887872
if (operation === 'soft-delete' && historyId === undefined)
888873
throw usageError('--history-id is required for soft-delete');
889874

890-
const number =
891-
family === 'pr'
892-
? parsePullNumber(requireOnePositional(parsed, 'pull request number'))
893-
: parseIssueNumber(requireOnePositional(parsed, 'issue number'));
894-
const repo = resolveRepo(parsed, env);
875+
let repo: RepositoryRef;
876+
let number: number;
877+
if (family === 'pr') {
878+
({ repo, number } = resolvePullTarget(parsed, env));
879+
} else {
880+
number = parseIssueNumber(requireOnePositional(parsed, 'issue number'));
881+
repo = resolveRepo(parsed, env);
882+
}
895883
const service = await serviceFor(parsed, env);
896884
if (operation === 'overview') {
897885
return { overview: await service.contentHistoryOverview(repo, number) };
@@ -1225,6 +1213,36 @@ function resolveRepo(
12251213
return parseRepository(raw);
12261214
}
12271215

1216+
/**
1217+
* Resolve the repository and number for a pull request addressed by either
1218+
* form. A `--repo` that contradicts the URL is a usage error, because two
1219+
* explicit addresses disagreeing means one of them is wrong and guessing which
1220+
* would act on a repository the caller did not name. `FORGEJO_REPOSITORY` is
1221+
* ambient rather than explicit, so an explicit URL simply overrides it.
1222+
*/
1223+
function resolvePullTarget(
1224+
parsed: ParsedArgs,
1225+
env: NodeJS.ProcessEnv,
1226+
): { repo: RepositoryRef; number: number } {
1227+
const target = parsePullTarget(
1228+
requireOnePositional(parsed, 'pull request number'),
1229+
);
1230+
if (target.repo === null) {
1231+
return { repo: resolveRepo(parsed, env), number: target.number };
1232+
}
1233+
const flagRepo = stringFlag(parsed, '--repo');
1234+
if (
1235+
flagRepo !== undefined &&
1236+
parseRepository(flagRepo).fullName !== target.repo.fullName
1237+
) {
1238+
throw usageError(
1239+
`--repo ${flagRepo} contradicts the pull request URL (${target.repo.fullName})`,
1240+
['Drop --repo, or pass the number instead of the URL'],
1241+
);
1242+
}
1243+
return { repo: target.repo, number: target.number };
1244+
}
1245+
12281246
function withFlags(flags: FlagSpec): FlagSpec {
12291247
return { ...CONNECTION_FLAGS, ...flags };
12301248
}

‎src/forgejo.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,6 +1794,63 @@ export function parsePullNumber(raw: string): number {
17941794
return positiveInteger(raw, 'Pull request number');
17951795
}
17961796

1797+
/**
1798+
* A pull request addressed either by number, leaving the repository to
1799+
* `--repo`, or by a URL that carries the repository with it.
1800+
*/
1801+
export interface PullRequestTarget {
1802+
/** Null when the caller gave a bare number and the repository is elsewhere. */
1803+
repo: RepositoryRef | null;
1804+
number: number;
1805+
}
1806+
1807+
/**
1808+
* Accept the URL Forgejo prints for a pull request wherever a number is taken.
1809+
*
1810+
* A scheme is the discriminator, so anything without one still reaches
1811+
* `parsePullNumber` and fails with the same message it always did.
1812+
*
1813+
* The URL supplies the repository and the number and nothing else. Its host is
1814+
* deliberately ignored rather than adopted as the base URL: routing a
1815+
* credentialled request at whatever host appeared in an argument is the same
1816+
* hazard `CROSS_ORIGIN_REDIRECT` exists to refuse, and it would also break
1817+
* every host reached through a forward or a proxy, where the printed URL and
1818+
* the reachable one legitimately differ.
1819+
*/
1820+
export function parsePullTarget(raw: string): PullRequestTarget {
1821+
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) {
1822+
return { repo: null, number: parsePullNumber(raw) };
1823+
}
1824+
let url: URL;
1825+
try {
1826+
url = new URL(raw);
1827+
} catch {
1828+
throw usageError('Pull request URL is not a valid URL');
1829+
}
1830+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
1831+
throw usageError('Pull request URL must use http or https');
1832+
if (url.username !== '' || url.password !== '')
1833+
throw usageError('Pull request URL must not carry credentials');
1834+
1835+
// Scanned from the end so a repository named `pulls` cannot capture the
1836+
// match, and anchored on the `pulls/N` pair so the web form, the
1837+
// `/api/v1/repos/` form, a host served under a path prefix, and a trailing
1838+
// `/files` or `/commits` sub-page all resolve to the same target.
1839+
const segments = url.pathname.split('/').filter((segment) => segment !== '');
1840+
for (let index = segments.length - 2; index >= 2; index -= 1) {
1841+
if (segments[index] !== 'pulls') continue;
1842+
const candidate = segments[index + 1] ?? '';
1843+
if (!/^[1-9]\d*$/.test(candidate)) continue;
1844+
return {
1845+
repo: parseRepository(`${segments[index - 2]}/${segments[index - 1]}`),
1846+
number: parsePullNumber(candidate),
1847+
};
1848+
}
1849+
throw usageError(`Pull request URL has no OWNER/REPO/pulls/NUMBER: ${raw}`, [
1850+
'Expected a URL like https://forgejo.example/owner/repo/pulls/42',
1851+
]);
1852+
}
1853+
17971854
export function parseIssueNumber(raw: string): number {
17981855
return positiveInteger(raw, 'Issue number');
17991856
}

0 commit comments

Comments
 (0)