@osdlc/typed-gh 0.12.1 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @osdlc/typed-gh
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5598ee8: Author logins also drop the `app/` prefix that gh's `--json` export puts on a bot author (`{"is_bot":true,"login":"app/osdlc-release"}`). So `prList`/`prView`, REST (`osdlc-release[bot]`) and GraphQL (`osdlc-release`) all give the same `login`, and the prefix marks the author as a bot. `normalizeAuthorLogin` strips it too. **Breaking:** a caller that matched `app/<name>` on `author.login` now sees `<name>`. To filter by a bot with `prList({ author })`, pass `app/<login>` or `<login>[bot]`: gh's `--author` does not match the bare form.
8
+ - 50a340a: `Gh` and `AsyncGh` take a second constructor form, `new Gh({ repo, hostname?, runner? })`, for a client that names a repository and needs no directory. `repo` is required in that form: `new Gh({})` does not typecheck and throws `TypeError`, so clone-relative use still names its directory. Each call runs in `process.cwd()`, read when the call is made. `cwd` is now a getter that returns that directory, still typed `readonly string`. `at(cwd)` on such a client gives one rooted at `cwd`, and `withRepo` keeps the form. `new Gh(cwd, options?)` is unchanged. New types `GhRepoOptions` and `AsyncGhRepoOptions` are `GhOptions` and `AsyncGhOptions` with `repo` required.
9
+
10
+ `gh.cwd` is now a getter on the prototype rather than an own field. Reading it is unchanged. `{ ...gh }`, `Object.keys(gh)` and `JSON.stringify(gh)` no longer include it, and a subclass cannot redeclare it as a field. On a client built from `repo` alone, `raw`, `rawChecked` and an `api` endpoint's `{branch}` still read `process.cwd()`, as documented.
11
+
12
+ Fixed: `repoView` treats `hostname: ''` as no host, as every other verb does. Before, it sent `/owner/name`.
13
+ - 65740a9: `prList({ headPrefix })` lists every pull request whose head branch starts with a prefix such as `task/`. It sends a `head:<prefix>` search term, groups any `search` after it in parentheses so an `OR` there cannot escape the prefix, asks for `headRefName`, and filters the rows with `startsWith`. Callers no longer write that filter themselves. Because it goes through GitHub search, which returns at most 1000 results, a `limit` over 999 throws `RangeError`. It also throws when combined with `head`, and for an empty prefix or one holding whitespace, `"`, `(` or `)`.
14
+
3
15
  ## 0.12.1
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -184,19 +184,39 @@ Name a repository, a host, or both, and every call targets them instead. No
184
184
  clone is needed:
185
185
 
186
186
  ```ts
187
- const acme = new Gh(process.cwd(), { repo: 'acme/widgets', hostname: 'github.acme.com' })
187
+ const acme = new Gh({ repo: 'acme/widgets', hostname: 'github.acme.com' })
188
188
 
189
189
  acme.prView(42)
190
190
  acme.withRepo('acme/gadgets').prList()
191
191
  acme.api('repos/{owner}/{repo}/branches')
192
192
  ```
193
193
 
194
+ A client has two forms. `new Gh(cwd, options?)` runs every call in `cwd`, and
195
+ without `repo` gh reads the repository from the clone there.
196
+ `new Gh({ repo, hostname?, runner? })` names a repository and no directory.
197
+ `repo` is required in that form: `new Gh({})` and `new Gh({ hostname })` do
198
+ not typecheck and throw `TypeError`, so a client that reads a clone always
199
+ says which one. Its calls run in `process.cwd()`, read when each call is made.
200
+ No typed verb depends on that directory, but `raw` and `rawChecked` add no
201
+ `--repo`, and gh fills an `api` endpoint's `{branch}` from that directory's
202
+ checkout, so add `--repo` to a raw call or use `.at(dir)`. `gh.cwd` is that
203
+ directory, a `string` in both forms. `AsyncGh` takes the same two forms.
204
+
205
+ ```ts
206
+ const local = new Gh('/path/to/clone') // reads acme/widgets from the clone's remote
207
+ const remote = new Gh({ repo: 'acme/widgets' }) // needs no clone
208
+
209
+ remote.at('/path/to/clone') // a client rooted at the clone, still naming acme/widgets
210
+ local.searchPrs('is:open author:@me') // a host-only call works on either form
211
+ ```
212
+
194
213
  The target goes into the argv, so a failure's `argv` shows which repository a
195
214
  call hit. `pr`, `issue` and `label` calls get `--repo github.acme.com/acme/widgets`.
196
215
  `api` calls get `--hostname` and a filled `{owner}/{repo}`. `authStatus`
197
216
  asks about that host. `repoList` and `searchPrs` have no host flag in gh, so
198
217
  they get `GH_HOST` in the environment. `withRepo` and `at` keep the runner and
199
- the rest of the target.
218
+ the rest of the target. `at` on a client with no directory gives one rooted
219
+ at that directory. `withRepo` keeps the client's form.
200
220
 
201
221
  `hostname` is any GitHub host: `github.com`, a GitHub Enterprise Server host,
202
222
  or a `<tenant>.ghe.com` host. An empty string means no host, so a value read
@@ -234,14 +254,26 @@ after the merge still finds it.
234
254
  `task/foo-2`. Two forks can share a branch name. Ask for
235
255
  `headRepositoryOwner` to tell them apart.
236
256
 
257
+ `prList({ headPrefix: 'task/' })` lists every PR whose head branch starts
258
+ with `task/`. It sends a `head:task/` search term, with your own `search`
259
+ grouped after it in parentheses, and filters the rows on `headRefName`. It
260
+ cannot be combined with `head`. Because it goes through GitHub search, a
261
+ just-pushed branch can take a moment to show up, and `limit` is at most 999,
262
+ as search returns no more than 1000 results.
263
+
237
264
  ## Two payload differences it handles
238
265
 
239
- Author logins differ between GraphQL and REST. GraphQL (`gh pr view --json`)
240
- returns a login without a suffix (`github-actions`). REST (`gh api`) returns
241
- the suffixed one (`github-actions[bot]`). Comparing one with the other never
242
- matches, and no error shows it. Then a bot's comments are treated as human
243
- review feedback. Logins are cut to the form without the suffix once, when a
244
- payload is parsed.
266
+ A bot's login is spelled three ways. GraphQL, and comment and review authors
267
+ in `gh pr view --json`, give the bare login (`github-actions`). gh's `--json`
268
+ export of a pull request's author prefixes it (`app/github-actions`). REST
269
+ (`gh api`) and `gh search` suffix it (`github-actions[bot]`). Comparing one with another
270
+ never matches, and no error shows it. Then a bot's comments are treated as
271
+ human review feedback. Logins are cut to the bare form once, when a payload is
272
+ parsed, and the prefix or suffix marks the author as a bot.
273
+
274
+ gh's `--author` filter does not take the bare form of a bot's login: pass
275
+ `app/<login>` or `<login>[bot]` to `prList({ author })` to list a bot's pull
276
+ requests.
245
277
 
246
278
  `statusCheckRollup` holds two node types. A modern `CheckRun` carries
247
279
  `name`/`status`/`conclusion`. A legacy `StatusContext` carries
@@ -525,10 +557,12 @@ experimental.
525
557
 
526
558
  ### Clients
527
559
 
528
- - `Gh`: the synchronous client, bound to one working directory, and optionally a repository and
529
- host.
560
+ - `Gh`: the synchronous client, bound to one working directory or one repository, and optionally a
561
+ host. `new Gh(cwd, options?)` or `new Gh({ repo, hostname?, runner? })`.
530
562
  - `AsyncGh`: the awaitable client, with the same methods.
531
563
  - `GhOptions` and `AsyncGhOptions`: the runner, `repo` and `hostname` a client is built with.
564
+ - `GhRepoOptions` and `AsyncGhRepoOptions`: the same with `repo` required, for a client built
565
+ without a directory.
532
566
  - `AsyncCallOptions`: `timeoutMs` and `signal`, accepted by sixteen methods of `AsyncGh`.
533
567
  - `defaultRunnerFactory` and `RunnerFactory`: how a client makes its default runner for `gh`.
534
568
  - `runVerb` and `runVerbAsync`, with `RunVerbOptions` and `RunVerbAsyncOptions`: run one verb through
@@ -585,7 +619,7 @@ Each verb takes an optional `GhTarget` last. Each client method has a verb of th
585
619
 
586
620
  - `parsePullRequest`: reads a `gh --json` payload into a `PullRequest`, or `null`.
587
621
  - `anyCheckFailed`: true if any check in a list failed.
588
- - `normalizeAuthorLogin`: drops a `[bot]` suffix from a login.
622
+ - `normalizeAuthorLogin`: drops a `[bot]` suffix or an `app/` prefix from a login.
589
623
  - `parseOwnerRepo`: reads a remote URL into `{ owner, repo }`, or `null`. It is
590
624
  `@osdlc/typed-git`'s `parseRemoteRepo` without the host, so any host's remote reads, not only
591
625
  GitHub's. Use `parseRemoteRepo` when you need the host too.
package/dist/async.d.ts CHANGED
@@ -34,22 +34,40 @@ export interface AsyncCallOptions {
34
34
  export interface AsyncGhOptions extends GhTarget {
35
35
  runner?: AsyncCommandRunner | undefined;
36
36
  }
37
+ /** {@link AsyncGhOptions} with `repo` required: what `new AsyncGh(options)` takes. See `GhRepoOptions`. */
38
+ export interface AsyncGhRepoOptions extends AsyncGhOptions {
39
+ repo: string;
40
+ }
37
41
  /**
38
- * A typed, awaitable gh client bound to one working directory, and optionally
39
- * to one repository and host. `repo` and `hostname` work as on `Gh`.
42
+ * A typed, awaitable gh client bound to one working directory or one named
43
+ * repository, and optionally to one host. The two constructor forms, `repo`
44
+ * and `hostname` work as on `Gh`: `new AsyncGh(cwd, options?)` runs in
45
+ * `cwd`, and `new AsyncGh({ repo, hostname?, runner? })` runs each call in
46
+ * `process.cwd()`, read when the call is made. As on `Gh`, `raw`,
47
+ * `rawChecked` and an `api` endpoint's `{branch}` still read that directory.
40
48
  */
41
49
  export declare class AsyncGh {
42
- readonly cwd: string;
43
50
  readonly repo: string | undefined;
44
51
  readonly hostname: string | undefined;
52
+ private readonly boundCwd;
45
53
  private readonly target;
46
54
  private readonly runner;
47
55
  private tried;
48
56
  private softened;
49
- constructor(cwd: string, opts?: AsyncGhOptions);
57
+ /** A client for the repository `options.repo`, with no working directory of its own. */
58
+ constructor(options: AsyncGhRepoOptions);
59
+ /** A client rooted at `cwd`: the clone gh reads, unless `options.repo` names another repository. */
60
+ constructor(cwd: string, options?: AsyncGhOptions);
61
+ /** Either form, for a value typed `string | AsyncGhRepoOptions`. */
62
+ constructor(cwdOrOptions: string | AsyncGhRepoOptions);
63
+ /** The directory calls run in. See `Gh.cwd`. */
64
+ get cwd(): string;
50
65
  /** The same client rooted at a different directory (same runner, repo and host). */
51
66
  at(cwd: string): AsyncGh;
52
- /** The same client aimed at another repository (same runner, directory and host). */
67
+ /**
68
+ * The same client aimed at another repository (same runner, directory and
69
+ * host). A client built from options alone stays without a directory.
70
+ */
53
71
  withRepo(repo: string): AsyncGh;
54
72
  /**
55
73
  * The whole surface again, resolving to a `Result<T, GhFailure>` instead of
@@ -59,9 +77,14 @@ export declare class AsyncGh {
59
77
  get try(): TriedAsync<AsyncGh>;
60
78
  /** Each query again, resolving `null` instead of rejecting when `gh` fails. See `Gh.soft`. */
61
79
  get soft(): Soft<AsyncGh>;
62
- /** Run any `gh` call and return the result, whatever the exit code. */
80
+ /**
81
+ * Run any `gh` call and return the result, whatever the exit code. It adds
82
+ * no `--repo`: on a client built without a directory it runs in
83
+ * `process.cwd()`, so gh reads that clone. Add `--repo` to `argv`, or call
84
+ * `.at(dir)` first.
85
+ */
63
86
  raw(argv: string[], opts?: AsyncCallOptions): Promise<CommandResult>;
64
- /** Run any `gh` call. Rejects with the classified failure on a non-zero exit. */
87
+ /** Run any `gh` call. Rejects with the classified failure on a non-zero exit. Adds no `--repo`, as {@link AsyncGh.raw}. */
65
88
  rawChecked(argv: string[], opts?: AsyncCallOptions): Promise<CommandResult>;
66
89
  /** Run one verb from the table, checked or unchecked as the verb says. */
67
90
  private exec;
package/dist/async.js CHANGED
@@ -18,32 +18,47 @@ import * as v from "./verbs.js";
18
18
  /** How long to wait between merge-state retries, in milliseconds. */
19
19
  const MERGE_STATE_RETRY_DELAY_MS = 1_500;
20
20
  /**
21
- * A typed, awaitable gh client bound to one working directory, and optionally
22
- * to one repository and host. `repo` and `hostname` work as on `Gh`.
21
+ * A typed, awaitable gh client bound to one working directory or one named
22
+ * repository, and optionally to one host. The two constructor forms, `repo`
23
+ * and `hostname` work as on `Gh`: `new AsyncGh(cwd, options?)` runs in
24
+ * `cwd`, and `new AsyncGh({ repo, hostname?, runner? })` runs each call in
25
+ * `process.cwd()`, read when the call is made. As on `Gh`, `raw`,
26
+ * `rawChecked` and an `api` endpoint's `{branch}` still read that directory.
23
27
  */
24
28
  export class AsyncGh {
25
- cwd;
26
29
  repo;
27
30
  hostname;
31
+ boundCwd;
28
32
  target;
29
33
  runner;
30
34
  tried;
31
35
  softened;
32
- constructor(cwd, opts = {}) {
36
+ constructor(cwdOrOptions, options = {}) {
37
+ const opts = typeof cwdOrOptions === 'string' ? options : cwdOrOptions;
38
+ if (typeof cwdOrOptions !== 'string')
39
+ v.requireRepo('AsyncGh', opts);
33
40
  v.checkTarget(opts);
34
- this.cwd = cwd;
41
+ this.boundCwd = typeof cwdOrOptions === 'string' ? cwdOrOptions : undefined;
35
42
  this.repo = opts.repo;
36
43
  this.hostname = opts.hostname;
37
44
  this.target = { repo: opts.repo, hostname: opts.hostname };
38
45
  this.runner = opts.runner ?? spawnRunnerAsync('gh', { absolutePathOnly: true });
39
46
  }
47
+ /** The directory calls run in. See `Gh.cwd`. */
48
+ get cwd() {
49
+ return this.boundCwd ?? process.cwd();
50
+ }
40
51
  /** The same client rooted at a different directory (same runner, repo and host). */
41
52
  at(cwd) {
42
53
  return new AsyncGh(cwd, { runner: this.runner, repo: this.repo, hostname: this.hostname });
43
54
  }
44
- /** The same client aimed at another repository (same runner, directory and host). */
55
+ /**
56
+ * The same client aimed at another repository (same runner, directory and
57
+ * host). A client built from options alone stays without a directory.
58
+ */
45
59
  withRepo(repo) {
46
- return new AsyncGh(this.cwd, { runner: this.runner, repo, hostname: this.hostname });
60
+ const opts = { runner: this.runner, repo, hostname: this.hostname };
61
+ return this.boundCwd === undefined ? new AsyncGh(opts) : new AsyncGh(this.boundCwd, opts);
47
62
  }
48
63
  /**
49
64
  * The whole surface again, resolving to a `Result<T, GhFailure>` instead of
@@ -58,11 +73,16 @@ export class AsyncGh {
58
73
  return (this.softened ??= softAsync(this));
59
74
  }
60
75
  // ── raw calls ─────────────────────────────────────────────────────────────
61
- /** Run any `gh` call and return the result, whatever the exit code. */
76
+ /**
77
+ * Run any `gh` call and return the result, whatever the exit code. It adds
78
+ * no `--repo`: on a client built without a directory it runs in
79
+ * `process.cwd()`, so gh reads that clone. Add `--repo` to `argv`, or call
80
+ * `.at(dir)` first.
81
+ */
62
82
  raw(argv, opts = {}) {
63
83
  return this.runner.run(argv, { cwd: this.cwd, ...opts });
64
84
  }
65
- /** Run any `gh` call. Rejects with the classified failure on a non-zero exit. */
85
+ /** Run any `gh` call. Rejects with the classified failure on a non-zero exit. Adds no `--repo`, as {@link AsyncGh.raw}. */
66
86
  rawChecked(argv, opts = {}) {
67
87
  return runCheckedGhAsync(this.runner, argv, { cwd: this.cwd, ...opts });
68
88
  }
package/dist/index.d.ts CHANGED
@@ -18,11 +18,11 @@
18
18
  *
19
19
  * Authentication is `gh`'s. No token is read, stored, or forwarded.
20
20
  */
21
- export { AsyncGh, type AsyncCallOptions, type AsyncGhOptions } from './async.ts';
21
+ export { AsyncGh, type AsyncCallOptions, type AsyncGhOptions, type AsyncGhRepoOptions, } from './async.ts';
22
22
  export { classifyGhFailure, type GhFailure, type GhFailureKind } from './failure.ts';
23
23
  export { type Soft, type TriedAsync, type TriedSync } from './doors.ts';
24
24
  export { runVerb, runVerbAsync, type RunVerbAsyncOptions, type RunVerbOptions } from './run.ts';
25
- export { Gh, defaultRunnerFactory, type GhOptions, type RunnerFactory } from './sync.ts';
25
+ export { Gh, defaultRunnerFactory, type GhOptions, type GhRepoOptions, type RunnerFactory, } from './sync.ts';
26
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';
27
27
  export { anyCheckFailed, normalizeAuthorLogin, parsePullRequest } from './parse.ts';
28
28
  export { ownerRepoSlug, parseOwnerRepo, parsePrRef, parsePrUrl, type PrRef, type PullRequestRef, } from './repo.ts';
package/dist/index.js CHANGED
@@ -18,11 +18,11 @@
18
18
  *
19
19
  * Authentication is `gh`'s. No token is read, stored, or forwarded.
20
20
  */
21
- export { AsyncGh } from "./async.js";
21
+ export { AsyncGh, } from "./async.js";
22
22
  export { classifyGhFailure } from "./failure.js";
23
23
  export {} from "./doors.js";
24
24
  export { runVerb, runVerbAsync } from "./run.js";
25
- export { Gh, defaultRunnerFactory } from "./sync.js";
25
+ export { Gh, defaultRunnerFactory, } from "./sync.js";
26
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";
27
27
  export { anyCheckFailed, normalizeAuthorLogin, parsePullRequest } from "./parse.js";
28
28
  export { ownerRepoSlug, parseOwnerRepo, parsePrRef, parsePrUrl, } from "./repo.js";
package/dist/parse.d.ts CHANGED
@@ -6,13 +6,14 @@
6
6
  *
7
7
  * It handles two payload differences:
8
8
  *
9
- * 1. Author logins differ between GraphQL and REST. GraphQL
10
- * (`gh pr view --json`) returns a login without a suffix
11
- * (`github-actions`). REST (`gh api`) returns the suffixed one
12
- * (`github-actions[bot]`). Comparing one with the other never matches,
13
- * and no error shows it. Then a bot's comments are treated as human
14
- * review feedback. Logins are normalized here, once, to the form without
15
- * the suffix.
9
+ * 1. A bot's login is spelled three ways. GraphQL (`gh api graphql`, and a
10
+ * comment's author in `gh pr view --json`) gives the bare login
11
+ * (`github-actions`). gh's own `--json` export of a pull request's
12
+ * author prefixes it (`app/github-actions`, with `is_bot: true`). REST
13
+ * (`gh api`) suffixes it (`github-actions[bot]`). Comparing one with
14
+ * another never matches, and no error shows it. Then a bot's comments
15
+ * are treated as human review feedback. Logins are normalized here, once,
16
+ * to the bare form.
16
17
  * 2. `statusCheckRollup` holds two node types. A modern `CheckRun` carries
17
18
  * `name`/`status`/`conclusion`. A legacy `StatusContext` carries
18
19
  * `context`/`state`. Both map to one verdict, so a caller never has to
@@ -20,13 +21,15 @@
20
21
  */
21
22
  import type { Actor, CheckRun, Label, PrSearchHit, PullRequest, Repository } from './types.ts';
22
23
  /**
23
- * Strip a single trailing `[bot]` suffix, so a REST login matches the
24
- * GraphQL one.
24
+ * The bare login: a single trailing `[bot]` (REST) or a leading `app/` (gh's
25
+ * `--json` export of a bot author) is stripped, so every spelling of one bot
26
+ * compares equal. A user login cannot hold `/` or `[`, so neither strip can
27
+ * change one.
25
28
  */
26
29
  export declare function normalizeAuthorLogin(login: string): string;
27
30
  /**
28
31
  * Parse an author node from GraphQL (`author`) or REST (`user`).
29
- * Null when there is no login, or the login is empty once `[bot]` is removed.
32
+ * Null when there is no login, or the login is empty once `[bot]` or `app/` is removed.
30
33
  */
31
34
  export declare function parseActor(x: unknown): Actor | null;
32
35
  /** One `statusCheckRollup` entry, whichever node type it is. */
package/dist/parse.js CHANGED
@@ -6,13 +6,14 @@
6
6
  *
7
7
  * It handles two payload differences:
8
8
  *
9
- * 1. Author logins differ between GraphQL and REST. GraphQL
10
- * (`gh pr view --json`) returns a login without a suffix
11
- * (`github-actions`). REST (`gh api`) returns the suffixed one
12
- * (`github-actions[bot]`). Comparing one with the other never matches,
13
- * and no error shows it. Then a bot's comments are treated as human
14
- * review feedback. Logins are normalized here, once, to the form without
15
- * the suffix.
9
+ * 1. A bot's login is spelled three ways. GraphQL (`gh api graphql`, and a
10
+ * comment's author in `gh pr view --json`) gives the bare login
11
+ * (`github-actions`). gh's own `--json` export of a pull request's
12
+ * author prefixes it (`app/github-actions`, with `is_bot: true`). REST
13
+ * (`gh api`) suffixes it (`github-actions[bot]`). Comparing one with
14
+ * another never matches, and no error shows it. Then a bot's comments
15
+ * are treated as human review feedback. Logins are normalized here, once,
16
+ * to the bare form.
16
17
  * 2. `statusCheckRollup` holds two node types. A modern `CheckRun` carries
17
18
  * `name`/`status`/`conclusion`. A legacy `StatusContext` carries
18
19
  * `context`/`state`. Both map to one verdict, so a caller never has to
@@ -48,15 +49,21 @@ function list(x) {
48
49
  }
49
50
  // ── actors ──────────────────────────────────────────────────────────────────
50
51
  /**
51
- * Strip a single trailing `[bot]` suffix, so a REST login matches the
52
- * GraphQL one.
52
+ * The bare login: a single trailing `[bot]` (REST) or a leading `app/` (gh's
53
+ * `--json` export of a bot author) is stripped, so every spelling of one bot
54
+ * compares equal. A user login cannot hold `/` or `[`, so neither strip can
55
+ * change one.
53
56
  */
54
57
  export function normalizeAuthorLogin(login) {
55
- return login.endsWith('[bot]') ? login.slice(0, -'[bot]'.length) : login;
58
+ if (login.endsWith('[bot]'))
59
+ return login.slice(0, -'[bot]'.length);
60
+ if (login.startsWith('app/'))
61
+ return login.slice('app/'.length);
62
+ return login;
56
63
  }
57
64
  /**
58
65
  * Parse an author node from GraphQL (`author`) or REST (`user`).
59
- * Null when there is no login, or the login is empty once `[bot]` is removed.
66
+ * Null when there is no login, or the login is empty once `[bot]` or `app/` is removed.
60
67
  */
61
68
  export function parseActor(x) {
62
69
  if (!isRecord(x))
@@ -67,8 +74,8 @@ export function parseActor(x) {
67
74
  return null;
68
75
  return {
69
76
  login,
70
- // Only some payloads carry `is_bot`. The suffix works for both.
71
- isBot: bool(x['is_bot']) || raw.endsWith('[bot]'),
77
+ // Only some payloads carry `is_bot`. The suffix and prefix mark a bot too.
78
+ isBot: bool(x['is_bot']) || login !== raw,
72
79
  };
73
80
  }
74
81
  // ── checks ──────────────────────────────────────────────────────────────────
package/dist/sync.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `Gh`: the synchronous GitHub client, the companion of `@osdlc/typed-git`'s
3
- * `Git`, bound to one working directory and one {@link CommandRunner}.
3
+ * `Git`, bound to one {@link CommandRunner} and either one working directory
4
+ * or one named repository.
4
5
  *
5
6
  * `gh` handles authentication. No token is read, stored, or passed. That is
6
7
  * why this runs the CLI instead of calling the REST API. The credential is
@@ -37,31 +38,64 @@ export interface GhOptions extends GhTarget {
37
38
  runner?: CommandRunner | undefined;
38
39
  }
39
40
  /**
40
- * A typed gh client bound to one working directory, and optionally to one
41
- * repository and host.
41
+ * {@link GhOptions} with `repo` required: what `new Gh(options)` takes when
42
+ * no working directory is given. Requiring `repo` keeps clone-relative use
43
+ * explicit: a client that reads the repository from a clone must say which
44
+ * directory the clone is in.
45
+ */
46
+ export interface GhRepoOptions extends GhOptions {
47
+ repo: string;
48
+ }
49
+ /**
50
+ * A typed gh client bound to one working directory or one named repository,
51
+ * and optionally to one host.
42
52
  *
43
- * With `repo`, every call names that repository (`--repo`, or the
44
- * `{owner}/{repo}` of an `api` endpoint), so no clone is needed. Without it,
45
- * gh uses the repository of the clone at `cwd`. With `hostname`, every call
46
- * goes to that GitHub host. An invalid `repo` or `hostname` throws
47
- * `RangeError`.
53
+ * `new Gh(cwd, options?)` runs every call in `cwd`. With `repo`, every call
54
+ * names that repository (`--repo`, or the `{owner}/{repo}` of an `api`
55
+ * endpoint), so no clone is needed. Without it, gh uses the repository of
56
+ * the clone at `cwd`.
57
+ *
58
+ * `new Gh({ repo, hostname?, runner? })` names a repository and no
59
+ * directory. Each call runs in `process.cwd()`, read when the call is made,
60
+ * because a runner needs a directory. No typed verb depends on it: each one
61
+ * names `repo`, and the host-only verbs (`repoList`, `searchPrs`) read no
62
+ * clone. Two escape hatches do: `raw` and `rawChecked` add no `--repo`, and
63
+ * an `api` endpoint's `{branch}` is filled by gh from the directory's
64
+ * checkout. `repo` is required in this form, so `new Gh({})` does not
65
+ * typecheck and throws `TypeError`.
66
+ *
67
+ * With `hostname`, every call goes to that GitHub host. An invalid `repo` or
68
+ * `hostname` throws `RangeError`.
48
69
  *
49
70
  * Every `timeoutMs` option is a hard cap on the call. An invalid value
50
71
  * throws, as in command-seam: `RangeError` for 0, a negative value or `NaN`;
51
72
  * `TypeError` for a non-number.
52
73
  */
53
74
  export declare class Gh {
54
- readonly cwd: string;
55
75
  readonly repo: string | undefined;
56
76
  readonly hostname: string | undefined;
77
+ private readonly boundCwd;
57
78
  private readonly target;
58
79
  private readonly runner;
59
80
  private tried;
60
81
  private softened;
61
- constructor(cwd: string, opts?: GhOptions);
82
+ /** A client for the repository `options.repo`, with no working directory of its own. */
83
+ constructor(options: GhRepoOptions);
84
+ /** A client rooted at `cwd`: the clone gh reads, unless `options.repo` names another repository. */
85
+ constructor(cwd: string, options?: GhOptions);
86
+ /** Either form, for a value typed `string | GhRepoOptions`. */
87
+ constructor(cwdOrOptions: string | GhRepoOptions);
88
+ /**
89
+ * The directory calls run in: the `cwd` the client was built with, or for
90
+ * a client built from options alone, `process.cwd()` at the time it is read.
91
+ */
92
+ get cwd(): string;
62
93
  /** The same client rooted at a different directory (same runner, repo and host). */
63
94
  at(cwd: string): Gh;
64
- /** The same client aimed at another repository (same runner, directory and host). */
95
+ /**
96
+ * The same client aimed at another repository (same runner, directory and
97
+ * host). A client built from options alone stays without a directory.
98
+ */
65
99
  withRepo(repo: string): Gh;
66
100
  /**
67
101
  * The whole surface again, returning a `Result<T, GhFailure>` instead of
@@ -81,11 +115,16 @@ export declare class Gh {
81
115
  * `try` where the difference matters.
82
116
  */
83
117
  get soft(): Soft<Gh>;
84
- /** Run any `gh` call and return the result, whatever the exit code. */
118
+ /**
119
+ * Run any `gh` call and return the result, whatever the exit code. It adds
120
+ * no `--repo`: on a client built without a directory it runs in
121
+ * `process.cwd()`, so gh reads that clone. Add `--repo` to `argv`, or call
122
+ * `.at(dir)` first.
123
+ */
85
124
  raw(argv: string[], opts?: {
86
125
  timeoutMs?: number | undefined;
87
126
  }): CommandResult;
88
- /** Run any `gh` call. Throws the classified failure on a non-zero exit. */
127
+ /** Run any `gh` call. Throws the classified failure on a non-zero exit. Adds no `--repo`, as {@link Gh.raw}. */
89
128
  rawChecked(argv: string[], opts?: {
90
129
  timeoutMs?: number | undefined;
91
130
  }): CommandResult;
package/dist/sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `Gh`: the synchronous GitHub client, the companion of `@osdlc/typed-git`'s
3
- * `Git`, bound to one working directory and one {@link CommandRunner}.
3
+ * `Git`, bound to one {@link CommandRunner} and either one working directory
4
+ * or one named repository.
4
5
  *
5
6
  * `gh` handles authentication. No token is read, stored, or passed. That is
6
7
  * why this runs the CLI instead of calling the REST API. The credential is
@@ -29,42 +30,67 @@ export const defaultRunnerFactory = (binary) => spawnRunner(binary, { absolutePa
29
30
  /** How long to wait between merge-state retries, in milliseconds. */
30
31
  const MERGE_STATE_RETRY_DELAY_MS = 1_500;
31
32
  /**
32
- * A typed gh client bound to one working directory, and optionally to one
33
- * repository and host.
33
+ * A typed gh client bound to one working directory or one named repository,
34
+ * and optionally to one host.
34
35
  *
35
- * With `repo`, every call names that repository (`--repo`, or the
36
- * `{owner}/{repo}` of an `api` endpoint), so no clone is needed. Without it,
37
- * gh uses the repository of the clone at `cwd`. With `hostname`, every call
38
- * goes to that GitHub host. An invalid `repo` or `hostname` throws
39
- * `RangeError`.
36
+ * `new Gh(cwd, options?)` runs every call in `cwd`. With `repo`, every call
37
+ * names that repository (`--repo`, or the `{owner}/{repo}` of an `api`
38
+ * endpoint), so no clone is needed. Without it, gh uses the repository of
39
+ * the clone at `cwd`.
40
+ *
41
+ * `new Gh({ repo, hostname?, runner? })` names a repository and no
42
+ * directory. Each call runs in `process.cwd()`, read when the call is made,
43
+ * because a runner needs a directory. No typed verb depends on it: each one
44
+ * names `repo`, and the host-only verbs (`repoList`, `searchPrs`) read no
45
+ * clone. Two escape hatches do: `raw` and `rawChecked` add no `--repo`, and
46
+ * an `api` endpoint's `{branch}` is filled by gh from the directory's
47
+ * checkout. `repo` is required in this form, so `new Gh({})` does not
48
+ * typecheck and throws `TypeError`.
49
+ *
50
+ * With `hostname`, every call goes to that GitHub host. An invalid `repo` or
51
+ * `hostname` throws `RangeError`.
40
52
  *
41
53
  * Every `timeoutMs` option is a hard cap on the call. An invalid value
42
54
  * throws, as in command-seam: `RangeError` for 0, a negative value or `NaN`;
43
55
  * `TypeError` for a non-number.
44
56
  */
45
57
  export class Gh {
46
- cwd;
47
58
  repo;
48
59
  hostname;
60
+ boundCwd;
49
61
  target;
50
62
  runner;
51
63
  tried;
52
64
  softened;
53
- constructor(cwd, opts = {}) {
65
+ constructor(cwdOrOptions, options = {}) {
66
+ const opts = typeof cwdOrOptions === 'string' ? options : cwdOrOptions;
67
+ if (typeof cwdOrOptions !== 'string')
68
+ v.requireRepo('Gh', opts);
54
69
  v.checkTarget(opts);
55
- this.cwd = cwd;
70
+ this.boundCwd = typeof cwdOrOptions === 'string' ? cwdOrOptions : undefined;
56
71
  this.repo = opts.repo;
57
72
  this.hostname = opts.hostname;
58
73
  this.target = { repo: opts.repo, hostname: opts.hostname };
59
74
  this.runner = opts.runner ?? defaultRunnerFactory('gh');
60
75
  }
76
+ /**
77
+ * The directory calls run in: the `cwd` the client was built with, or for
78
+ * a client built from options alone, `process.cwd()` at the time it is read.
79
+ */
80
+ get cwd() {
81
+ return this.boundCwd ?? process.cwd();
82
+ }
61
83
  /** The same client rooted at a different directory (same runner, repo and host). */
62
84
  at(cwd) {
63
85
  return new Gh(cwd, { runner: this.runner, repo: this.repo, hostname: this.hostname });
64
86
  }
65
- /** The same client aimed at another repository (same runner, directory and host). */
87
+ /**
88
+ * The same client aimed at another repository (same runner, directory and
89
+ * host). A client built from options alone stays without a directory.
90
+ */
66
91
  withRepo(repo) {
67
- return new Gh(this.cwd, { runner: this.runner, repo, hostname: this.hostname });
92
+ const opts = { runner: this.runner, repo, hostname: this.hostname };
93
+ return this.boundCwd === undefined ? new Gh(opts) : new Gh(this.boundCwd, opts);
68
94
  }
69
95
  /**
70
96
  * The whole surface again, returning a `Result<T, GhFailure>` instead of
@@ -89,11 +115,16 @@ export class Gh {
89
115
  return (this.softened ??= softSync(this));
90
116
  }
91
117
  // ── raw calls ─────────────────────────────────────────────────────────────
92
- /** Run any `gh` call and return the result, whatever the exit code. */
118
+ /**
119
+ * Run any `gh` call and return the result, whatever the exit code. It adds
120
+ * no `--repo`: on a client built without a directory it runs in
121
+ * `process.cwd()`, so gh reads that clone. Add `--repo` to `argv`, or call
122
+ * `.at(dir)` first.
123
+ */
93
124
  raw(argv, opts = {}) {
94
125
  return this.runner.run(argv, { cwd: this.cwd, ...opts });
95
126
  }
96
- /** Run any `gh` call. Throws the classified failure on a non-zero exit. */
127
+ /** Run any `gh` call. Throws the classified failure on a non-zero exit. Adds no `--repo`, as {@link Gh.raw}. */
97
128
  rawChecked(argv, opts = {}) {
98
129
  return runCheckedGh(this.runner, argv, { cwd: this.cwd, ...opts });
99
130
  }
package/dist/types.d.ts CHANGED
@@ -41,10 +41,10 @@ export type PullRequestState = 'OPEN' | 'CLOSED' | 'MERGED' | (string & {});
41
41
  export type ReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null;
42
42
  /** The state of one review. `PENDING` is a review its author has not submitted yet. */
43
43
  export type ReviewState = 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING' | (string & {});
44
- /** Who wrote something. `login` is normalized: a `[bot]` suffix is stripped. */
44
+ /** Who wrote something. `login` is normalized: a `[bot]` suffix or `app/` prefix is stripped. */
45
45
  export interface Actor {
46
46
  login: string;
47
- /** True when GitHub marks the account as a bot, or the login carried `[bot]`. */
47
+ /** True when GitHub marks the account as a bot, or the login carried `[bot]` or `app/`. */
48
48
  isBot: boolean;
49
49
  }
50
50
  /** A repository label. `color` is hex digits such as `d73a4a`. A field the payload lacks is `''`. */
package/dist/verbs.d.ts CHANGED
@@ -71,6 +71,11 @@ export interface GhTarget {
71
71
  * no text can reach gh as a flag or change the endpoint a call hits.
72
72
  */
73
73
  export declare function checkTarget(target: GhTarget | undefined): void;
74
+ /**
75
+ * Throws `TypeError` unless `opts.repo` is set: the options-only form names
76
+ * a repository, so a missing one would silently read the process's own clone.
77
+ */
78
+ export declare function requireRepo(client: string, opts: GhTarget | undefined): void;
74
79
  /**
75
80
  * The smallest useful set: enough to identify a PR and nothing more.
76
81
  *
@@ -120,6 +125,24 @@ export interface PrListOptions {
120
125
  * apart.
121
126
  */
122
127
  head?: string | undefined;
128
+ /**
129
+ * Every PR whose head branch starts with this prefix, such as `task/`.
130
+ *
131
+ * Sent as a `head:<prefix>` term of a GitHub search, with any `search`
132
+ * grouped after it in parentheses, so an `OR` there cannot escape the
133
+ * prefix. The rows are filtered again on `headRefName`, since GitHub's
134
+ * `head:` match ignores case and git branch names do not.
135
+ *
136
+ * Going through search brings search's limits: a short index lag after a
137
+ * push, the search rate limit, and at most 1000 results, so a `limit` over
138
+ * 999 throws `RangeError`. `truncated` counts the rows gh returned before
139
+ * the case filter, so it can be true with fewer than `limit` rows.
140
+ *
141
+ * Passing it with `head` throws `RangeError`, as does an empty prefix or
142
+ * one holding whitespace, `"`, `(` or `)`, which GitHub search would read
143
+ * as syntax.
144
+ */
145
+ headPrefix?: string | undefined;
123
146
  /** Raw `--search` query, for anything the flags do not cover. */
124
147
  search?: string | undefined;
125
148
  author?: string | undefined;
@@ -193,7 +216,8 @@ export interface ApiOptions {
193
216
  /**
194
217
  * `gh api [-i] [-H If-None-Match: …] <endpoint>`: a GET against the REST API. `{owner}` and
195
218
  * `{repo}` placeholders in the endpoint are filled from the target's repo, or by gh from the
196
- * cwd's repo when the target names none.
219
+ * cwd's repo when the target names none. gh fills `{branch}` from the cwd's checked-out branch
220
+ * whatever the target, so it reads the working directory even on a client built from `repo`.
197
221
  * Unchecked. Failure is a value (`ok: false`), so a caller can tell a 304
198
222
  * from an outage.
199
223
  */
package/dist/verbs.js CHANGED
@@ -48,6 +48,15 @@ export function checkTarget(target) {
48
48
  throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(hostname)}`);
49
49
  }
50
50
  }
51
+ /**
52
+ * Throws `TypeError` unless `opts.repo` is set: the options-only form names
53
+ * a repository, so a missing one would silently read the process's own clone.
54
+ */
55
+ export function requireRepo(client, opts) {
56
+ if (typeof opts !== 'object' || opts === null || opts.repo === undefined) {
57
+ throw new TypeError(`new ${client}(options) needs options.repo; pass a directory, as new ${client}(cwd, options), for a clone`);
58
+ }
59
+ }
51
60
  /**
52
61
  * `[host/]owner/name` for `repo` (else the target's repo), the form `--repo`
53
62
  * and `gh repo view` take, or `undefined` when neither names one. The host is
@@ -243,34 +252,55 @@ function manyIssuesOrNull(r) {
243
252
  export function prList(opts = {}, target) {
244
253
  const limit = opts.limit ?? 100;
245
254
  checkLimit(limit);
255
+ const { head, headPrefix } = opts;
256
+ if (headPrefix !== undefined) {
257
+ if (head !== undefined)
258
+ throw new RangeError('prList: pass head or headPrefix, not both');
259
+ if (limit > 999) {
260
+ throw new RangeError(`prList: headPrefix goes through GitHub search, which returns at most 1000 results; limit ${limit} is over 999`);
261
+ }
262
+ if (headPrefix === '' || /[\s"()]/.test(headPrefix)) {
263
+ throw new RangeError(`prList: headPrefix must be a non-empty branch prefix, got ${JSON.stringify(headPrefix)}`);
264
+ }
265
+ }
246
266
  const argv = ['pr', 'list', '--state', opts.state ?? 'open', ...repoArgs(target, opts.repo)];
247
267
  if (opts.base !== undefined)
248
268
  argv.push('--base', opts.base);
249
- if (opts.head !== undefined)
250
- argv.push('--head', opts.head);
251
- if (opts.search !== undefined)
252
- argv.push('--search', opts.search);
269
+ if (head !== undefined)
270
+ argv.push('--head', head);
271
+ // The caller's search is grouped so its `OR` cannot escape the prefix:
272
+ // GitHub search binds AND tighter than OR.
273
+ const own = opts.search?.trim();
274
+ const search = headPrefix === undefined
275
+ ? opts.search
276
+ : own === undefined || own === ''
277
+ ? `head:${headPrefix}`
278
+ : `head:${headPrefix} (${own})`;
279
+ if (search !== undefined)
280
+ argv.push('--search', search);
253
281
  if (opts.author !== undefined)
254
282
  argv.push('--author', opts.author);
255
283
  // A row without `number` does not parse and is dropped, so always ask for it.
256
- // The exact-head filter below also reads `headRefName`.
284
+ // The head filters below also read `headRefName`.
285
+ const filtered = head !== undefined || headPrefix !== undefined;
257
286
  const asked = withFields(opts.fields ?? PR_LIST_FIELDS, [
258
287
  'number',
259
- ...(opts.head === undefined ? [] : ['headRefName']),
288
+ ...(filtered ? ['headRefName'] : []),
260
289
  ]);
261
290
  argv.push('--json', asked.join(','));
262
291
  argv.push('--limit', String(limit + 1));
263
- const head = opts.head;
292
+ const keep = (name) => head !== undefined ? name === head : headPrefix === undefined || name.startsWith(headPrefix);
264
293
  return {
265
294
  argv,
266
295
  checked: false,
267
296
  readOnly: true,
268
297
  parse(r) {
269
298
  // A search stops at 1000 results, so 1000 rows may be a cut, not the end.
270
- const result = limited(manyOrNull(r), limit, (opts.search ?? '') !== '' || opts.author !== undefined ? SEARCH_RESULT_CAP : undefined);
271
- if (result === null || head === undefined)
299
+ // `search` here is the one sent, `head:<prefix>` included.
300
+ const result = limited(manyOrNull(r), limit, (search ?? '') !== '' || opts.author !== undefined ? SEARCH_RESULT_CAP : undefined);
301
+ if (result === null || !filtered)
272
302
  return result;
273
- return { ...result, rows: result.rows.filter((pr) => pr.headRefName === head) };
303
+ return { ...result, rows: result.rows.filter((pr) => keep(pr.headRefName)) };
274
304
  },
275
305
  };
276
306
  }
@@ -342,7 +372,8 @@ export function prBody(number, target) {
342
372
  /**
343
373
  * `gh api [-i] [-H If-None-Match: …] <endpoint>`: a GET against the REST API. `{owner}` and
344
374
  * `{repo}` placeholders in the endpoint are filled from the target's repo, or by gh from the
345
- * cwd's repo when the target names none.
375
+ * cwd's repo when the target names none. gh fills `{branch}` from the cwd's checked-out branch
376
+ * whatever the target, so it reads the working directory even on a client built from `repo`.
346
377
  * Unchecked. Failure is a value (`ok: false`), so a caller can tell a 304
347
378
  * from an outage.
348
379
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/typed-gh",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "A typed GitHub client for TypeScript over the gh CLI. A total PullRequest/Review/CheckRun model, pure argv builders and parsers, and a tri-modal client — queries return parsed values, mutations throw, raw() is the escape hatch. Uses gh's own credentials; reads no token.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "sksizer",
@@ -26,9 +26,9 @@
26
26
  "LICENSE-APACHE"
27
27
  ],
28
28
  "dependencies": {
29
- "@osdlc/command-seam": "^0.14.0",
30
29
  "@osdlc/typed-git": "^0.11.1",
31
- "@osdlc/result": "^0.1.0"
30
+ "@osdlc/result": "^0.1.0",
31
+ "@osdlc/command-seam": "^0.14.0"
32
32
  },
33
33
  "engines": {
34
34
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"