@osdlc/typed-gh 0.5.0 → 0.7.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/dist/doors.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The three doors, as `@osdlc/typed-git` has them: the plain verb (checked),
3
+ * `gh.try.*` and `gh.soft.*`.
4
+ *
5
+ * - **Plain / checked**: a value, or a throw. A failed `gh` throws a
6
+ * `FailureError` carrying the classified `GhFailure` (its `cause` is the
7
+ * `CommandFailed`), so a caller that reports it, such as `runMain`, reads
8
+ * "signed out" or "rate limited" and not a bare exit; output that exited 0
9
+ * but could not be read throws one of kind `unreadable`. A verb that
10
+ * reads one thing (`prView`, `issueView`, `repoView`) answers `null` when
11
+ * GitHub says there is no such thing: absence is an answer.
12
+ * - **`try`**: the same call, with the failure returned as
13
+ * `Result<T, GhFailure>`. It catches a thrown failure (a `FailureError`, or a
14
+ * `CommandFailed` from `raw`-level code) and nothing else: a `RangeError` for a bad argument still throws.
15
+ * - **`soft`**: the same call, with `null` for a failure. Only the queries
16
+ * have a soft form; no mutation does.
17
+ *
18
+ * Both are facades over the whole client, so a new method costs nothing here.
19
+ */
20
+ import { CommandFailed, isCommandFailed, runChecked, runCheckedAsync, } from '@osdlc/command-seam';
21
+ import { err, FailureError, isFailure, ok } from '@osdlc/result';
22
+ import { classifyGhFailure, unreadableFailure } from "./failure.js";
23
+ /**
24
+ * Read the result of running `verb` as the plain door does.
25
+ *
26
+ * A checked verb has already thrown on a non-zero exit (the runner is
27
+ * `runChecked`), and a probe (`installed`, `authStatus`, `api`) carries its
28
+ * failure in its value. A read that exited non-zero throws `CommandFailed`,
29
+ * unless the verb's `absent` says gh's answer was "there is no such thing",
30
+ * which `parse` turns into `null`. A read that exited 0 but parsed to `null`
31
+ * could not be read completely, and throws.
32
+ *
33
+ * @throws `FailureError` carrying the classified {@link GhFailure} for a failed run.
34
+ * @throws `FailureError` carrying an `unreadable` {@link GhFailure}.
35
+ */
36
+ export function settle(verb, result, binary) {
37
+ if (verb.checked || verb.probe === true)
38
+ return verb.parse(result);
39
+ if (result.neverExited === true || result.exitCode !== 0) {
40
+ if (result.neverExited !== true && verb.absent?.(result) === true)
41
+ return verb.parse(result);
42
+ throw ghFailureError(new CommandFailed(verb.argv, result, binary));
43
+ }
44
+ const value = verb.parse(result);
45
+ if (value === null) {
46
+ throw new FailureError(unreadableFailure(verb.argv, result.stdout, binary));
47
+ }
48
+ return value;
49
+ }
50
+ /**
51
+ * The error the plain door throws for a failed run: a `FailureError` carrying
52
+ * the {@link GhFailure} that {@link classifyGhFailure} reads from it, with the
53
+ * `CommandFailed` as its `cause`. Anything else is returned as it is.
54
+ */
55
+ export function ghFailureError(error) {
56
+ return isCommandFailed(error) ? new FailureError(classifyGhFailure(error)) : error;
57
+ }
58
+ /** {@link runChecked}, throwing {@link ghFailureError}'s classified failure. */
59
+ export function runCheckedGh(runner, argv, opts) {
60
+ try {
61
+ return runChecked(runner, argv, opts);
62
+ }
63
+ catch (error) {
64
+ throw ghFailureError(error);
65
+ }
66
+ }
67
+ /** {@link runCheckedAsync}, rejecting with {@link ghFailureError}'s classified failure. */
68
+ export async function runCheckedGhAsync(runner, argv, opts) {
69
+ try {
70
+ return await runCheckedAsync(runner, argv, opts);
71
+ }
72
+ catch (error) {
73
+ throw ghFailureError(error);
74
+ }
75
+ }
76
+ /**
77
+ * The queries that have a soft form. A mutation has none: swallowing the failure
78
+ * of a write is how a caller ends up believing it worked.
79
+ */
80
+ export const SOFT_METHODS = [
81
+ 'prList',
82
+ 'prView',
83
+ 'prFind',
84
+ 'openPrFor',
85
+ 'prViewSettled',
86
+ 'prBody',
87
+ 'graphql',
88
+ 'labelList',
89
+ 'issueList',
90
+ 'issueView',
91
+ 'repoList',
92
+ 'repoView',
93
+ 'searchPrs',
94
+ 'prReviewThreads',
95
+ ];
96
+ /**
97
+ * Whether `error` carries a failure to hand back rather than a bug to let
98
+ * through. By shape, not `instanceof`: two installed copies of command-seam or
99
+ * result each have their own classes.
100
+ */
101
+ function isGhFailure(error) {
102
+ return (typeof error === 'object' &&
103
+ error !== null &&
104
+ isFailure(error.failure));
105
+ }
106
+ /** The failure a caught `CommandFailed` or `FailureError` stands for. */
107
+ export function failureOf(error) {
108
+ const thrown = error;
109
+ return isCommandFailed(thrown) ? classifyGhFailure(thrown) : error.failure;
110
+ }
111
+ function facade(client, wrap) {
112
+ return new Proxy(client, {
113
+ get(target, prop) {
114
+ // Read from the target, not the proxy: a getter (`try` itself) must run
115
+ // against the real client rather than recursing through the facade.
116
+ const value = Reflect.get(target, prop);
117
+ if (typeof value !== 'function')
118
+ return value;
119
+ const method = value;
120
+ const name = String(prop);
121
+ return (...args) => wrap(() => method.apply(target, args), name);
122
+ },
123
+ });
124
+ }
125
+ /** Wrap a synchronous client so every method returns a `Result<T, GhFailure>`. */
126
+ export function triedSync(client) {
127
+ return facade(client, (call) => {
128
+ try {
129
+ return ok(call());
130
+ }
131
+ catch (error) {
132
+ if (isGhFailure(error))
133
+ return err(failureOf(error));
134
+ throw error;
135
+ }
136
+ });
137
+ }
138
+ /** Wrap an awaitable client so every method promises a `Result<T, GhFailure>`. */
139
+ export function triedAsync(client) {
140
+ return facade(client, async (call) => {
141
+ try {
142
+ return ok(await call());
143
+ }
144
+ catch (error) {
145
+ if (isGhFailure(error))
146
+ return err(failureOf(error));
147
+ throw error;
148
+ }
149
+ });
150
+ }
151
+ function checkSoft(name) {
152
+ if (!SOFT_METHODS.includes(name)) {
153
+ throw new TypeError(`gh.soft.${name} has no soft form; call it through gh.try (or the plain method) instead`);
154
+ }
155
+ }
156
+ /** Wrap a synchronous client so a failed query returns `null`. */
157
+ export function softSync(client) {
158
+ return facade(client, (call, name) => {
159
+ checkSoft(name);
160
+ try {
161
+ return call();
162
+ }
163
+ catch (error) {
164
+ if (isGhFailure(error))
165
+ return null;
166
+ throw error;
167
+ }
168
+ });
169
+ }
170
+ /** Wrap an awaitable client so a failed query resolves to `null`. */
171
+ export function softAsync(client) {
172
+ return facade(client, async (call, name) => {
173
+ checkSoft(name);
174
+ try {
175
+ return await call();
176
+ }
177
+ catch (error) {
178
+ if (isGhFailure(error))
179
+ return null;
180
+ throw error;
181
+ }
182
+ });
183
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * {@link GhFailure}: a failed `gh` call, classified. The `gh.try.*` door
3
+ * returns it as the error of a `Result`; its `cause` is the `CommandFailed`
4
+ * it was read from.
5
+ *
6
+ * The kinds beyond the subprocess layer's come from `gh`'s stderr, matched in
7
+ * English. A message that matches none stays the subprocess layer's `exit`
8
+ * with `gh`'s first stderr line, never a wrong kind.
9
+ */
10
+ import { type CommandFailed, type CommandFailureKind } from '@osdlc/command-seam';
11
+ import { type Failure } from '@osdlc/result';
12
+ /**
13
+ * Why a `gh` call failed:
14
+ *
15
+ * - everything `@osdlc/command-seam` reports (`not-installed`, `not-executable`,
16
+ * `bad-cwd`, `timeout`, `output-cap`, `signal`, `aborted`, `spawn`, `exit`);
17
+ * - `not-authenticated`: `gh` has no usable credentials, or GitHub refused
18
+ * them ("gh auth login", "HTTP 401", "HTTP 403", "Bad credentials"). A 403 that
19
+ * is a rate limit stays `rate-limited`;
20
+ * - `network`: GitHub could not be reached ("error connecting to
21
+ * api.github.com", "Could not resolve host", "dial tcp", "i/o timeout");
22
+ * - `not-found`: GitHub has no such thing, or hides it ("Could not resolve to a
23
+ * PullRequest", "HTTP 404", "no pull requests found for branch");
24
+ * - `rate-limited`: "API rate limit exceeded", "secondary rate limit", "HTTP 429";
25
+ * - `unreadable`: `gh` exited 0 but its output could not be read completely
26
+ * (malformed JSON, a partial GraphQL answer, a review-thread list cut short).
27
+ */
28
+ export type GhFailureKind = CommandFailureKind | 'not-authenticated' | 'network' | 'not-found' | 'rate-limited' | 'unreadable';
29
+ /** A failed `gh` call. `message` is one line for a person; `detail` is `gh`'s own output. */
30
+ export interface GhFailure extends Failure<GhFailureKind> {
31
+ /** The binary that ran (`gh`), when known. */
32
+ binary?: string | undefined;
33
+ /** The arguments `gh` ran with, without the binary name. */
34
+ argv: string[];
35
+ /** `gh`'s exit code; `null` when it never reported one. */
36
+ exitCode: number | null;
37
+ /** The signal that ended `gh`, when the runner knows it. */
38
+ signal?: NodeJS.Signals | null | undefined;
39
+ /** The `CommandFailed` this was classified from; absent for `unreadable`, where `gh` succeeded. */
40
+ cause?: CommandFailed | undefined;
41
+ }
42
+ /** What a verb that reads one thing asks GitHub for. */
43
+ export type Thing = 'pr' | 'issue' | 'repo';
44
+ /**
45
+ * Whether the text of a failed `gh` run (stderr then stdout, as
46
+ * {@link classifyGhFailure} reads it) means "there is no such `thing`", for a
47
+ * verb that reads one thing: an answer, not a failure.
48
+ *
49
+ * Narrower than the `not-found` failure kind on purpose. A mistyped `--repo`
50
+ * makes `gh pr view 7` say "Could not resolve to a Repository", which is a
51
+ * wrong repository rather than a missing pull request, so it is a `not-found`
52
+ * failure and not absence. Likewise a bare `HTTP 404` does not say which part
53
+ * of the request was missing.
54
+ */
55
+ export declare function isAbsent(thing: Thing, text: string): boolean;
56
+ /** The gh failure for a failed `gh` run. */
57
+ export declare function classifyGhFailure(failed: CommandFailed): GhFailure;
58
+ /** The failure for a run that exited 0 but whose output the verb could not read. */
59
+ export declare function unreadableFailure(argv: readonly string[], stdout: string, binary: string | undefined): GhFailure;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * {@link GhFailure}: a failed `gh` call, classified. The `gh.try.*` door
3
+ * returns it as the error of a `Result`; its `cause` is the `CommandFailed`
4
+ * it was read from.
5
+ *
6
+ * The kinds beyond the subprocess layer's come from `gh`'s stderr, matched in
7
+ * English. A message that matches none stays the subprocess layer's `exit`
8
+ * with `gh`'s first stderr line, never a wrong kind.
9
+ */
10
+ import { classifyFailure } from '@osdlc/command-seam';
11
+ import {} from '@osdlc/result';
12
+ const INSTALL_HINT = 'Install it from https://cli.github.com, then run `gh auth login`.';
13
+ const RATE_LIMITED = /API rate limit exceeded|secondary rate limit|rate limit exceeded|HTTP 429/i;
14
+ const NOT_AUTHENTICATED = /gh auth login|HTTP 401|HTTP 403|Bad credentials|authentication required|not logged in/i;
15
+ const NETWORK = /error connecting to|Could not resolve host|no such host|dial tcp|i\/o timeout|network is unreachable|connection refused/i;
16
+ const NOT_FOUND = /Could not resolve to an? [A-Za-z]+|HTTP 404|no pull requests found for branch|\(HTTP 404\)|Not Found/;
17
+ const ABSENT = {
18
+ pr: /Could not resolve to a PullRequest|no pull requests found for branch/,
19
+ issue: /Could not resolve to an Issue/,
20
+ repo: /Could not resolve to a Repository/,
21
+ };
22
+ /**
23
+ * Whether the text of a failed `gh` run (stderr then stdout, as
24
+ * {@link classifyGhFailure} reads it) means "there is no such `thing`", for a
25
+ * verb that reads one thing: an answer, not a failure.
26
+ *
27
+ * Narrower than the `not-found` failure kind on purpose. A mistyped `--repo`
28
+ * makes `gh pr view 7` say "Could not resolve to a Repository", which is a
29
+ * wrong repository rather than a missing pull request, so it is a `not-found`
30
+ * failure and not absence. Likewise a bare `HTTP 404` does not say which part
31
+ * of the request was missing.
32
+ */
33
+ export function isAbsent(thing, text) {
34
+ return ABSENT[thing].test(text);
35
+ }
36
+ /** The gh failure for a failed `gh` run. */
37
+ export function classifyGhFailure(failed) {
38
+ // The client's runner may not name its binary; this is gh either way, so the
39
+ // messages say `gh` rather than "The command".
40
+ const f = failed.binary === undefined
41
+ ? (classifyFailure(failed.result, { binary: 'gh', argv: failed.argv }) ?? failed.failure)
42
+ : failed.failure;
43
+ const base = { ...f, cause: failed };
44
+ if (f.kind === 'not-installed')
45
+ return { ...base, hint: INSTALL_HINT };
46
+ if (f.kind !== 'exit')
47
+ return base;
48
+ const text = `${failed.result.stderr}\n${failed.result.stdout}`;
49
+ if (RATE_LIMITED.test(text)) {
50
+ return {
51
+ ...base,
52
+ kind: 'rate-limited',
53
+ message: "GitHub's API rate limit was reached.",
54
+ hint: 'Wait a few minutes and try again.',
55
+ };
56
+ }
57
+ if (NOT_AUTHENTICATED.test(text)) {
58
+ return {
59
+ ...base,
60
+ kind: 'not-authenticated',
61
+ message: 'gh is not signed in to GitHub.',
62
+ hint: 'Run `gh auth login`.',
63
+ };
64
+ }
65
+ if (NETWORK.test(text)) {
66
+ return {
67
+ ...base,
68
+ kind: 'network',
69
+ message: 'GitHub could not be reached.',
70
+ hint: 'Check your network connection.',
71
+ };
72
+ }
73
+ if (NOT_FOUND.test(text)) {
74
+ return {
75
+ ...base,
76
+ kind: 'not-found',
77
+ message: 'GitHub has no such pull request, issue or repository, or you cannot see it.',
78
+ hint: 'Check the number or name, and that your account can see the repository.',
79
+ };
80
+ }
81
+ return base;
82
+ }
83
+ /** The failure for a run that exited 0 but whose output the verb could not read. */
84
+ export function unreadableFailure(argv, stdout, binary) {
85
+ return {
86
+ kind: 'unreadable',
87
+ message: 'gh answered, but its output could not be read completely.',
88
+ ...(stdout.trim() === '' ? {} : { detail: stdout.trim().slice(0, 2000) }),
89
+ binary: binary ?? 'gh',
90
+ argv: [...argv],
91
+ exitCode: 0,
92
+ };
93
+ }
package/dist/index.d.ts CHANGED
@@ -11,16 +11,20 @@
11
11
  * - **Pure argv builders and parsers.** The verb table and `parse.ts` never
12
12
  * spawn anything, so an async fan-out drives them through its own runner
13
13
  * without duplicating a line of parsing.
14
- * - **A client.** `Gh`, tri-modal like `Git`: queries return parsed values
15
- * and never throw, mutations throw `CommandFailed`, `raw` is the hatch.
14
+ * - **A client.** `Gh`, with `Git`'s three doors: the plain method returns a
15
+ * value or throws a `FailureError` carrying the classified
16
+ * `GhFailure`; `gh.try.*` returns a
17
+ * `Result<T, GhFailure>`; `gh.soft.*` returns `null` for a failed query.
18
+ * `raw` is the hatch.
16
19
  *
17
20
  * Authentication is `gh`'s. No token is read, stored, or forwarded.
18
21
  */
19
- export { AsyncGh, type AsyncCallOptions } from './async.ts';
20
- export { Gh, defaultRunnerFactory, type RunnerFactory } from './sync.ts';
21
- export { api, authStatus, createPr, editPr, editPrBody, createLabel, createReview, editLabel, installed, issueList, issueView, labelList, openPrsForBranch, pickPrForBranch, prBody, prComment, prSetDraft, issueCommentEdit, prDiff, prList, prMerge, prReviewThreads, prUpdateBranch, prView, setIssueLabel, PR_LIST_FIELDS, PR_REF_FIELDS, PR_VIEW_FIELDS, type ApiOptions, type ApiResponse, type AuthStatus, type CreateLabelOptions, type CreatePrOptions, type CreateReviewOptions, type GhVerb, type IssueListOptions, type PrListOptions, type PrMergeMethod, type ReviewComment, type ReviewThread, } from './verbs.ts';
22
+ export { AsyncGh, type AsyncCallOptions, type AsyncGhOptions } from './async.ts';
23
+ export { classifyGhFailure, type GhFailure, type GhFailureKind } from './failure.ts';
24
+ export { type Soft, type TriedAsync, type TriedSync } from './doors.ts';
25
+ export { Gh, defaultRunnerFactory, type GhOptions, type RunnerFactory } from './sync.ts';
26
+ export { api, authStatus, createPr, editPr, editPrBody, graphql, createLabel, createReview, editLabel, installed, issueList, issueView, labelList, nodesAt, openPrsForBranch, pickPrForBranch, prBody, prComment, prSetDraft, issueCommentEdit, prDiff, prList, prMerge, prReviewThreads, prUpdateBranch, prView, repoList, repoView, searchPrs, setIssueLabel, PR_LIST_FIELDS, PR_REF_FIELDS, PR_VIEW_FIELDS, REPO_FIELDS, SEARCH_PR_FIELDS, type ApiOptions, type ApiResponse, type AuthStatus, type CreateLabelOptions, type CreatePrOptions, type CreateReviewOptions, type GhTarget, type GraphqlOptions, type GraphqlVariable, type GhVerb, type IssueListOptions, type ListResult, type PrListOptions, type PrMergeMethod, type RepoListOptions, type ReviewComment, type ReviewThread, type SearchPrsOptions, } from './verbs.ts';
22
27
  export { anyCheckFailed, normalizeAuthorLogin, parsePullRequest } from './parse.ts';
23
- export { ownerRepoSlug, parseOwnerRepo, parsePrUrl, type PullRequestRef } from './repo.ts';
24
- export type { Actor, CheckConclusion, CheckRun, Comment, IssueRow, Label, MergeableState, MergeStateStatus, OwnerRepo, PullRequest, PullRequestState, Review, ReviewDecision, ReviewState, } from './types.ts';
28
+ export { ownerRepoSlug, parseOwnerRepo, parsePrRef, parsePrUrl, type PrRef, type PullRequestRef, } from './repo.ts';
29
+ export type { Actor, CheckConclusion, CheckRun, Comment, IssueRow, Label, MergeableState, MergeStateStatus, OwnerRepo, PullRequest, PrSearchHit, PullRequestState, Repository, RepositoryVisibility, Review, ReviewDecision, ReviewState, } from './types.ts';
25
30
  export { CommandFailed, type AsyncCommandRunner, type CommandResult, type CommandRunner, type CommandRunOptions, } from '@osdlc/command-seam';
26
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -11,16 +11,21 @@
11
11
  * - **Pure argv builders and parsers.** The verb table and `parse.ts` never
12
12
  * spawn anything, so an async fan-out drives them through its own runner
13
13
  * without duplicating a line of parsing.
14
- * - **A client.** `Gh`, tri-modal like `Git`: queries return parsed values
15
- * and never throw, mutations throw `CommandFailed`, `raw` is the hatch.
14
+ * - **A client.** `Gh`, with `Git`'s three doors: the plain method returns a
15
+ * value or throws a `FailureError` carrying the classified
16
+ * `GhFailure`; `gh.try.*` returns a
17
+ * `Result<T, GhFailure>`; `gh.soft.*` returns `null` for a failed query.
18
+ * `raw` is the hatch.
16
19
  *
17
20
  * Authentication is `gh`'s. No token is read, stored, or forwarded.
18
21
  */
19
22
  export { AsyncGh } from "./async.js";
23
+ export { classifyGhFailure } from "./failure.js";
24
+ export {} from "./doors.js";
20
25
  export { Gh, defaultRunnerFactory } from "./sync.js";
21
- export { api, authStatus, createPr, editPr, editPrBody, createLabel, createReview, editLabel, installed, issueList, issueView, labelList, openPrsForBranch, pickPrForBranch, prBody, prComment, prSetDraft, issueCommentEdit, prDiff, prList, prMerge, prReviewThreads, prUpdateBranch, prView, setIssueLabel, PR_LIST_FIELDS, PR_REF_FIELDS, PR_VIEW_FIELDS, } from "./verbs.js";
26
+ export { api, authStatus, createPr, editPr, editPrBody, graphql, createLabel, createReview, editLabel, installed, issueList, issueView, labelList, nodesAt, openPrsForBranch, pickPrForBranch, prBody, prComment, prSetDraft, issueCommentEdit, prDiff, prList, prMerge, prReviewThreads, prUpdateBranch, prView, repoList, repoView, searchPrs, setIssueLabel, PR_LIST_FIELDS, PR_REF_FIELDS, PR_VIEW_FIELDS, REPO_FIELDS, SEARCH_PR_FIELDS, } from "./verbs.js";
22
27
  export { anyCheckFailed, normalizeAuthorLogin, parsePullRequest } from "./parse.js";
23
- export { ownerRepoSlug, parseOwnerRepo, parsePrUrl } from "./repo.js";
28
+ export { ownerRepoSlug, parseOwnerRepo, parsePrRef, parsePrUrl, } from "./repo.js";
24
29
  // The seam's error and runner types appear in this package's signatures, so
25
30
  // they are re-exported. Its functions are not: import them from
26
31
  // `@osdlc/command-seam`.
package/dist/parse.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * `StatusContext` carries `context`/`state`. They are folded onto one
18
18
  * verdict axis so a caller never has to know which it got.
19
19
  */
20
- import type { Actor, CheckRun, Label, PullRequest } from './types.ts';
20
+ import type { Actor, CheckRun, Label, PrSearchHit, PullRequest, Repository } from './types.ts';
21
21
  /**
22
22
  * Strip a single trailing `[bot]` suffix — REST shape folded to GraphQL shape.
23
23
  *
@@ -43,4 +43,7 @@ export declare function parseLabels(x: unknown): Label[];
43
43
  * any field subset.
44
44
  */
45
45
  export declare function parsePullRequest(raw: unknown): PullRequest | null;
46
- //# sourceMappingURL=parse.d.ts.map
46
+ /** A `gh repo list`/`view --json` row as a {@link Repository}, or null without `nameWithOwner`. */
47
+ export declare function parseRepository(raw: unknown): Repository | null;
48
+ /** A `gh search prs --json` row as a {@link PrSearchHit}, or null without a `number`. */
49
+ export declare function parsePrSearchHit(raw: unknown): PrSearchHit | null;
package/dist/parse.js CHANGED
@@ -223,3 +223,51 @@ export function parsePullRequest(raw) {
223
223
  updatedAt: strOrNull(raw['updatedAt'] ?? raw['updated_at']),
224
224
  };
225
225
  }
226
+ // ── repositories and search ─────────────────────────────────────────────────
227
+ /** A `gh repo list`/`view --json` row as a {@link Repository}, or null without `nameWithOwner`. */
228
+ export function parseRepository(raw) {
229
+ if (!isRecord(raw))
230
+ return null;
231
+ const nameWithOwner = str(raw['nameWithOwner']);
232
+ const slash = nameWithOwner.indexOf('/');
233
+ if (slash <= 0 || slash === nameWithOwner.length - 1)
234
+ return null;
235
+ const branch = raw['defaultBranchRef'];
236
+ return {
237
+ nameWithOwner,
238
+ owner: nameWithOwner.slice(0, slash),
239
+ name: nameWithOwner.slice(slash + 1),
240
+ description: str(raw['description']),
241
+ defaultBranch: isRecord(branch) ? strOrNull(branch['name']) : null,
242
+ visibility: strOrNull(raw['visibility']),
243
+ isArchived: bool(raw['isArchived']),
244
+ url: str(raw['url']),
245
+ };
246
+ }
247
+ /** A `gh search prs --json` row as a {@link PrSearchHit}, or null without a `number`. */
248
+ export function parsePrSearchHit(raw) {
249
+ if (!isRecord(raw))
250
+ return null;
251
+ const number = raw['number'];
252
+ if (typeof number !== 'number')
253
+ return null;
254
+ const repo = raw['repository'];
255
+ const slug = isRecord(repo) ? str(repo['nameWithOwner']) : '';
256
+ const slash = slug.indexOf('/');
257
+ return {
258
+ number,
259
+ title: str(raw['title']),
260
+ // Search answers in lower case; `PullRequest.state` is upper case.
261
+ state: str(raw['state']).toUpperCase(),
262
+ url: str(raw['url']),
263
+ isDraft: bool(raw['isDraft']),
264
+ repository: slash > 0 && slash < slug.length - 1
265
+ ? { owner: slug.slice(0, slash), repo: slug.slice(slash + 1) }
266
+ : null,
267
+ author: parseActor(raw['author']),
268
+ labels: parseLabels(raw['labels']),
269
+ createdAt: strOrNull(raw['createdAt']),
270
+ updatedAt: strOrNull(raw['updatedAt']),
271
+ closedAt: strOrNull(raw['closedAt']),
272
+ };
273
+ }
package/dist/repo.d.ts CHANGED
@@ -15,22 +15,53 @@ import type { OwnerRepo } from './types.ts';
15
15
  export declare function parseOwnerRepo(remoteUrl: string): OwnerRepo | null;
16
16
  /** `owner/repo`, the form `gh api` paths and `--repo` both take. */
17
17
  export declare function ownerRepoSlug(r: OwnerRepo): string;
18
- /** A pull request coordinate: the repository and the PR's number within it. */
18
+ /** A pull request coordinate: the host, the repository and the PR's number within it. */
19
19
  export interface PullRequestRef extends OwnerRepo {
20
+ /** The GitHub host, lower-cased: `github.com`, or an Enterprise host. */
21
+ hostname: string;
20
22
  number: number;
21
23
  }
22
24
  /**
23
- * The `{owner, repo, number}` a GitHub pull-request URL names, or null when
24
- * `url` is not one.
25
+ * The `{hostname, owner, repo, number}` a GitHub pull-request URL names, or
26
+ * null when `url` is not one.
25
27
  *
26
- * Accepts `https://github.com/<owner>/<repo>/pull/<n>` with or without a
27
- * scheme, a `www.` prefix, a trailing slash, a sub-page (`/files`,
28
- * `/commits`), a query string or a fragment — every shape a browser's address
29
- * bar hands over. `parseGitUrl` is the wrong tool here: it reads `/pull/<n>`
28
+ * Accepts `https://<host>/<owner>/<repo>/pull/<n>` with or without a scheme,
29
+ * a trailing slash, a sub-page (`/files`, `/commits`), a query string or a
30
+ * fragment — every shape a browser's address bar hands over. The host is
31
+ * `github.com` (a `www.` prefix is dropped) or a GitHub Enterprise host such
32
+ * as `github.acme.com` or `acme.ghe.com`; without a scheme the host must
33
+ * contain a dot. `parseGitUrl` is the wrong tool here: it reads `/pull/<n>`
30
34
  * as part of the project path.
31
- *
32
- * GitHub.com only. A GitHub Enterprise host is a different coordinate space
33
- * (the `gh` CLI needs `--hostname`), so it is not silently accepted.
34
35
  */
35
36
  export declare function parsePrUrl(url: string): PullRequestRef | null;
36
- //# sourceMappingURL=repo.d.ts.map
37
+ /**
38
+ * What a person typed to name a pull request, read by {@link parsePrRef}.
39
+ *
40
+ * - `number`: `42`, `#42`, or `owner/name#42` (then `repo` is set). For bare
41
+ * digits, `orBranch` holds the same text, since a branch can be named `42`.
42
+ * - `url`: a pull request URL, with what {@link parsePrUrl} read from it.
43
+ * - `branch`: any other text that is a valid branch name.
44
+ */
45
+ export type PrRef = {
46
+ kind: 'number';
47
+ number: number;
48
+ repo: OwnerRepo | null;
49
+ orBranch: string | null;
50
+ } | ({
51
+ kind: 'url';
52
+ url: string;
53
+ } & PullRequestRef) | {
54
+ kind: 'branch';
55
+ name: string;
56
+ };
57
+ /**
58
+ * Read what a person typed to name a pull request: a number, `#42`,
59
+ * `owner/name#42`, a URL, or a branch name. Null when it is none of these.
60
+ *
61
+ * Pure: it starts no process. Digits are read as a number first, the way
62
+ * `gh pr view 42` reads them; `orBranch` keeps the branch reading. The
63
+ * clients' `prFind` tries both.
64
+ */
65
+ export declare function parsePrRef(input: string): PrRef | null;
66
+ /** True for `owner/name` as GitHub spells it. */
67
+ export declare function isRepoSlug(text: string): boolean;
package/dist/repo.js CHANGED
@@ -26,24 +26,88 @@ export function ownerRepoSlug(r) {
26
26
  return `${r.owner}/${r.repo}`;
27
27
  }
28
28
  /**
29
- * The `{owner, repo, number}` a GitHub pull-request URL names, or null when
30
- * `url` is not one.
29
+ * The `{hostname, owner, repo, number}` a GitHub pull-request URL names, or
30
+ * null when `url` is not one.
31
31
  *
32
- * Accepts `https://github.com/<owner>/<repo>/pull/<n>` with or without a
33
- * scheme, a `www.` prefix, a trailing slash, a sub-page (`/files`,
34
- * `/commits`), a query string or a fragment — every shape a browser's address
35
- * bar hands over. `parseGitUrl` is the wrong tool here: it reads `/pull/<n>`
32
+ * Accepts `https://<host>/<owner>/<repo>/pull/<n>` with or without a scheme,
33
+ * a trailing slash, a sub-page (`/files`, `/commits`), a query string or a
34
+ * fragment — every shape a browser's address bar hands over. The host is
35
+ * `github.com` (a `www.` prefix is dropped) or a GitHub Enterprise host such
36
+ * as `github.acme.com` or `acme.ghe.com`; without a scheme the host must
37
+ * contain a dot. `parseGitUrl` is the wrong tool here: it reads `/pull/<n>`
36
38
  * as part of the project path.
37
- *
38
- * GitHub.com only. A GitHub Enterprise host is a different coordinate space
39
- * (the `gh` CLI needs `--hostname`), so it is not silently accepted.
40
39
  */
41
40
  export function parsePrUrl(url) {
42
- const m = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(url.trim());
41
+ const m = /^(?:(https?:)\/\/)?([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?)\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(url.trim());
43
42
  if (m === null)
44
43
  return null;
45
- const number = Number(m[3]);
44
+ const host = m[2].toLowerCase();
45
+ if (m[1] === undefined && !host.includes('.'))
46
+ return null;
47
+ const number = Number(m[5]);
46
48
  if (!Number.isSafeInteger(number) || number <= 0)
47
49
  return null;
48
- return { owner: m[1], repo: m[2].replace(/\.git$/, ''), number };
50
+ return {
51
+ hostname: host === 'www.github.com' ? 'github.com' : host,
52
+ owner: m[3],
53
+ repo: m[4].replace(/\.git$/, ''),
54
+ number,
55
+ };
56
+ }
57
+ /**
58
+ * Read what a person typed to name a pull request: a number, `#42`,
59
+ * `owner/name#42`, a URL, or a branch name. Null when it is none of these.
60
+ *
61
+ * Pure: it starts no process. Digits are read as a number first, the way
62
+ * `gh pr view 42` reads them; `orBranch` keeps the branch reading. The
63
+ * clients' `prFind` tries both.
64
+ */
65
+ export function parsePrRef(input) {
66
+ const text = input.trim();
67
+ const digits = /^(#?)(\d+)$/.exec(text);
68
+ if (digits !== null) {
69
+ const number = Number(digits[2]);
70
+ if (Number.isSafeInteger(number) && number > 0) {
71
+ return { kind: 'number', number, repo: null, orBranch: digits[1] === '' ? text : null };
72
+ }
73
+ }
74
+ const qualified = /^([^/\s#]+)\/([^/\s#]+)#(\d+)$/.exec(text);
75
+ if (qualified !== null) {
76
+ const number = Number(qualified[3]);
77
+ if (!Number.isSafeInteger(number) || number <= 0)
78
+ return null;
79
+ if (!isRepoSlug(`${qualified[1]}/${qualified[2]}`))
80
+ return null;
81
+ return {
82
+ kind: 'number',
83
+ number,
84
+ repo: { owner: qualified[1], repo: qualified[2] },
85
+ orBranch: null,
86
+ };
87
+ }
88
+ const url = parsePrUrl(text);
89
+ if (url !== null)
90
+ return { kind: 'url', url: text, ...url };
91
+ return isBranchName(text) ? { kind: 'branch', name: text } : null;
92
+ }
93
+ /** True for `owner/name` as GitHub spells it. */
94
+ export function isRepoSlug(text) {
95
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(text);
96
+ }
97
+ /**
98
+ * True when `name` is a branch name git would accept (`git check-ref-format
99
+ * --branch`), and does not start with `-`, so it cannot read as a flag.
100
+ */
101
+ function isBranchName(name) {
102
+ if (name === '' || name === '@' || name.startsWith('-') || name.startsWith('/'))
103
+ return false;
104
+ if (name.endsWith('/') || name.endsWith('.'))
105
+ return false;
106
+ // Control characters, space, and the characters git reserves.
107
+ // oxlint-disable-next-line no-control-regex
108
+ if (/[\x00-\x20\x7f~^:?*[\\]/.test(name))
109
+ return false;
110
+ if (name.includes('..') || name.includes('@{') || name.includes('//'))
111
+ return false;
112
+ return name.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock'));
49
113
  }