@osdlc/typed-gh 0.11.1 → 0.12.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,13 @@
1
1
  # @osdlc/typed-gh
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a4ca07d: New `@osdlc/typed-gh/testing` entry point with builders for faking `gh` in tests. `prRow`, `issueRow`, `repoRow`, `searchPrRow`, `labelRow`, `checkRunRow`, `statusContextRow`, `commentRow` and `reviewRow` return gh's raw `--json` rows, with defaults that fill every field the parsers require (a pull request's `number`, a repository's `nameWithOwner`, the `headRefName` that `prList({ head })` filters on), and take typed overrides merged shallowly. `ghResult(payload)` wraps rows in a successful `CommandResult` for a fake runner. The main entry point does not import it.
8
+
9
+ Fixed: `searchPrs` reads gh's zero time (`0001-01-01T00:00:00Z`), which gh prints for a hit that isn't closed, as `closedAt: null`. Before, an open hit had a year-0001 close date.
10
+
3
11
  ## 0.11.1
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -488,6 +488,36 @@ Import the runners themselves, such as `spawnRunner` and `spawnRunnerAsync`, fro
488
488
  named `gh` in the working directory cannot run in its place. Pass `{ absolutePathOnly: true }` when
489
489
  you build a real runner yourself.
490
490
 
491
+ ## Testing
492
+
493
+ `@osdlc/typed-gh/testing` builds the rows a fake runner answers with. Each builder returns gh's raw
494
+ `--json` shape, so the real parsers read it. Its defaults fill every field a parser requires, such
495
+ as a pull request's `number`, a repository's `nameWithOwner` and the `headRefName` that
496
+ `prList({ head })` filters on. Without them a row is dropped with no error.
497
+
498
+ - `prRow`, `issueRow`, `repoRow`, `searchPrRow` and `labelRow`: one row of `gh pr`, `gh issue`,
499
+ `gh repo`, `gh search prs` and `gh label` output.
500
+ - `checkRunRow`, `statusContextRow`, `commentRow` and `reviewRow`: entries of a pull request's
501
+ `statusCheckRollup`, `comments` and `reviews`.
502
+ - `ghResult(payload)`: a `CommandResult` with exit code 0 and the payload as JSON on stdout.
503
+ - Row types: `GhPrJson`, `GhIssueJson`, `GhRepoJson`, `GhSearchPrJson`, `GhLabelJson`,
504
+ `GhCheckRunJson`, `GhStatusContextJson`, `GhCommentJson`, `GhReviewJson` and `GhActorJson`, and
505
+ `RowOverrides<T>`, what a builder takes.
506
+
507
+ Each builder takes typed overrides, merged shallowly. Pass a field as `undefined` to leave it out.
508
+ A pull request's `url` follows its `number`, and a repository's its `nameWithOwner`. Assert on what
509
+ you set: a default value may change in a minor release.
510
+
511
+ ```ts
512
+ import { ghResult, prRow } from '@osdlc/typed-gh/testing'
513
+
514
+ const runner: CommandRunner = {
515
+ run: () => ghResult([prRow({ number: 7, headRefName: 'task/foo' }), prRow({ number: 8 })]),
516
+ }
517
+
518
+ new Gh('/repo', { runner }).openPrFor('task/foo')?.number // 7
519
+ ```
520
+
491
521
  ## API
492
522
 
493
523
  Everything below is exported from `@osdlc/typed-gh`. The Stability section says which items are
@@ -599,7 +629,8 @@ The experimental items:
599
629
  - `GraphqlOptions`
600
630
  - `GraphqlVariable`
601
631
 
602
- Every other export and method is stable, `prDiff` included.
632
+ Every other export and method is stable, `prDiff` included. So is `@osdlc/typed-gh/testing`: its
633
+ builders and their row types. Their default values may change in any minor release.
603
634
 
604
635
  ## Platforms
605
636
 
package/dist/parse.js CHANGED
@@ -266,6 +266,14 @@ export function parsePrSearchHit(raw) {
266
266
  labels: parseLabels(raw['labels']),
267
267
  createdAt: strOrNull(raw['createdAt']),
268
268
  updatedAt: strOrNull(raw['updatedAt']),
269
- closedAt: strOrNull(raw['closedAt']),
269
+ closedAt: searchTime(raw['closedAt']),
270
270
  };
271
271
  }
272
+ /**
273
+ * gh search prints Go's zero time (`0001-01-01T00:00:00Z`) for a time that is
274
+ * not set, such as `closedAt` on an open pull request. That is no time.
275
+ */
276
+ function searchTime(x) {
277
+ const s = strOrNull(x);
278
+ return s === null || s.startsWith('0001-01-01') ? null : s;
279
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `@osdlc/typed-gh/testing`: rows of `gh --json` output, for faking `gh` in
3
+ * tests.
4
+ *
5
+ * A separate subpath, not part of the package entry, so production code does
6
+ * not carry fixture data. Nothing in `src/` but this file knows about it.
7
+ *
8
+ * Each builder returns gh's raw JSON shape, what `gh … --json` prints
9
+ * (`nameWithOwner`, `headRefName`, `defaultBranchRef: { name }`), not the
10
+ * parsed model. Feed the rows to a fake runner through {@link ghResult} and
11
+ * the real parsers read them, so a test exercises the same parsing a real
12
+ * `gh` call does.
13
+ *
14
+ * The defaults fill every field the parsers require, which is where hand-made
15
+ * rows go wrong: a row the parser rejects is dropped without an error.
16
+ *
17
+ * - A pull request needs an integer `number` (`parsePullRequest`), and
18
+ * `prList({ head })` also keeps only rows whose `headRefName` equals
19
+ * `head`.
20
+ * - An issue and a search hit need a numeric `number`.
21
+ * - A repository needs `nameWithOwner` as `owner/name`, both parts
22
+ * non-empty (`parseRepository`).
23
+ * - A label needs a non-empty `name` (`parseLabels`).
24
+ * - A check needs a `name`, or a `context` for a legacy status context
25
+ * (`parseCheckRun`).
26
+ *
27
+ * Overrides merge shallowly: a field you pass replaces the default whole, and
28
+ * a field you pass as `undefined` is left out of the row, to test a payload
29
+ * that lacks it. A default that names another field (a pull request's `url`
30
+ * names its `number`) follows your override of that field. The default
31
+ * values may change in a minor release, as long as every parser still
32
+ * accepts them, so assert on what you set, not on a default.
33
+ */
34
+ import type { CommandResult } from '@osdlc/command-seam';
35
+ import type { CheckConclusion, MergeableState, MergeStateStatus, PullRequestState, RepositoryVisibility, ReviewState } from './types.ts';
36
+ /**
37
+ * What a builder takes: any field of the row, or `undefined` to leave that
38
+ * field out.
39
+ */
40
+ export type RowOverrides<T> = {
41
+ [K in keyof T]?: T[K] | undefined;
42
+ };
43
+ /**
44
+ * A successful `gh` call that printed `payload` as JSON: exit code 0, the
45
+ * payload on stdout with gh's trailing newline, and nothing on stderr. Pass an
46
+ * array of rows for a list verb and one row for a view verb.
47
+ */
48
+ export declare function ghResult(payload: unknown): CommandResult;
49
+ /**
50
+ * A pull request's or search hit's `author` as `gh --json` prints it. A bot
51
+ * is `{ is_bot: true, login: 'app/<name>' }`.
52
+ */
53
+ export interface GhActorJson {
54
+ login: string;
55
+ is_bot?: boolean;
56
+ name?: string;
57
+ id?: string;
58
+ }
59
+ /**
60
+ * A comment's or review's `author` as `gh --json` prints it: only `login`.
61
+ * gh leaves out `id`, `name` and `is_bot` there, so a bot comment does not
62
+ * read as a bot unless its login ends in `[bot]`.
63
+ */
64
+ export interface GhCommentAuthorJson {
65
+ login: string;
66
+ }
67
+ /** One entry of a `labels` field, or one row of `gh label list --json name,color,description`. */
68
+ export interface GhLabelJson {
69
+ name: string;
70
+ /** Six hex digits, no `#`. */
71
+ color: string;
72
+ description: string;
73
+ }
74
+ /** A label row. The default is GitHub's own `bug` label. */
75
+ export declare function labelRow(overrides?: RowOverrides<GhLabelJson>): GhLabelJson;
76
+ /** A modern `CheckRun` node of `statusCheckRollup`. */
77
+ export interface GhCheckRunJson {
78
+ __typename: 'CheckRun';
79
+ name: string;
80
+ workflowName: string;
81
+ /** `QUEUED`, `IN_PROGRESS` or `COMPLETED`. */
82
+ status: string;
83
+ /** gh prints `""` while the check runs. */
84
+ conclusion: Exclude<CheckConclusion, null> | '';
85
+ detailsUrl: string;
86
+ startedAt: string;
87
+ completedAt: string;
88
+ }
89
+ /** A check run. The default completed with `SUCCESS`, so it reads as a green check. */
90
+ export declare function checkRunRow(overrides?: RowOverrides<GhCheckRunJson>): GhCheckRunJson;
91
+ /** A legacy `StatusContext` node of `statusCheckRollup`. */
92
+ export interface GhStatusContextJson {
93
+ __typename: 'StatusContext';
94
+ context: string;
95
+ /** `SUCCESS`, `PENDING`, `FAILURE` or `ERROR`. */
96
+ state: string;
97
+ targetUrl: string;
98
+ startedAt: string;
99
+ }
100
+ /** A legacy status context. The default is a `SUCCESS`. */
101
+ export declare function statusContextRow(overrides?: RowOverrides<GhStatusContextJson>): GhStatusContextJson;
102
+ /** One entry of a pull request's `comments`. */
103
+ export interface GhCommentJson {
104
+ /** The GraphQL node id. */
105
+ id: string;
106
+ author: GhCommentAuthorJson | null;
107
+ authorAssociation: string;
108
+ body: string;
109
+ createdAt: string;
110
+ url: string;
111
+ }
112
+ /** A comment by `octocat`. */
113
+ export declare function commentRow(overrides?: RowOverrides<GhCommentJson>): GhCommentJson;
114
+ /** One entry of a pull request's `reviews`. */
115
+ export interface GhReviewJson {
116
+ /** The GraphQL node id. */
117
+ id: string;
118
+ author: GhCommentAuthorJson | null;
119
+ authorAssociation: string;
120
+ body: string;
121
+ state: ReviewState;
122
+ submittedAt: string;
123
+ commit: {
124
+ oid: string;
125
+ };
126
+ }
127
+ /** An approving review by `octocat`. */
128
+ export declare function reviewRow(overrides?: RowOverrides<GhReviewJson>): GhReviewJson;
129
+ /** A pull request as `gh pr list`/`view --json` prints it, with every field the parser reads. */
130
+ export interface GhPrJson {
131
+ /** Required: a row without an integer `number` is dropped. */
132
+ number: number;
133
+ state: PullRequestState;
134
+ title: string;
135
+ body: string;
136
+ url: string;
137
+ isDraft: boolean;
138
+ baseRefName: string;
139
+ /** Required by `prList({ head })`, which keeps only rows whose head is exactly `head`. */
140
+ headRefName: string;
141
+ headRefOid: string;
142
+ isCrossRepository: boolean;
143
+ mergeable: MergeableState;
144
+ mergeStateStatus: MergeStateStatus;
145
+ /** gh prints `""` when there is no decision. */
146
+ reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | '';
147
+ author: GhActorJson | null;
148
+ labels: GhLabelJson[];
149
+ comments: GhCommentJson[];
150
+ reviews: GhReviewJson[];
151
+ statusCheckRollup: (GhCheckRunJson | GhStatusContextJson)[];
152
+ mergedAt: string | null;
153
+ createdAt: string;
154
+ updatedAt: string;
155
+ }
156
+ /**
157
+ * An open, mergeable pull request #1 from `feature/add-retries` into `main`
158
+ * of `octo-org/hello-world`, by `octocat`, with no labels, comments, reviews
159
+ * or checks. Its `url` follows `number`.
160
+ */
161
+ export declare function prRow(overrides?: RowOverrides<GhPrJson>): GhPrJson;
162
+ /** An issue as `gh issue list`/`view --json number,title,body,labels,state` prints it. */
163
+ export interface GhIssueJson {
164
+ /** Required: a row without a numeric `number` is dropped. */
165
+ number: number;
166
+ title: string;
167
+ body: string;
168
+ labels: GhLabelJson[];
169
+ /** `OPEN` or `CLOSED`. */
170
+ state: string;
171
+ }
172
+ /** An open issue #1 with no labels. */
173
+ export declare function issueRow(overrides?: RowOverrides<GhIssueJson>): GhIssueJson;
174
+ /** A repository as `gh repo list`/`view --json` prints it, with every field the parser reads. */
175
+ export interface GhRepoJson {
176
+ /** Required: `owner/name`, both parts non-empty, or the row is dropped. */
177
+ nameWithOwner: string;
178
+ description: string;
179
+ /** `{ name: '' }` for an empty repository, as gh prints it. `null` parses the same. */
180
+ defaultBranchRef: {
181
+ name: string;
182
+ } | null;
183
+ visibility: RepositoryVisibility;
184
+ isArchived: boolean;
185
+ url: string;
186
+ }
187
+ /** The public, unarchived `octo-org/hello-world` on `main`. Its `url` follows `nameWithOwner`. */
188
+ export declare function repoRow(overrides?: RowOverrides<GhRepoJson>): GhRepoJson;
189
+ /** A row of `gh search prs --json`, with every field the parser reads. */
190
+ export interface GhSearchPrJson {
191
+ /** Required: a row without a numeric `number` is dropped. */
192
+ number: number;
193
+ title: string;
194
+ /** Search answers in lower case: `open`, `closed` or `merged`. */
195
+ state: string;
196
+ url: string;
197
+ isDraft: boolean;
198
+ repository: {
199
+ name: string;
200
+ nameWithOwner: string;
201
+ };
202
+ author: GhActorJson | null;
203
+ labels: GhLabelJson[];
204
+ createdAt: string;
205
+ updatedAt: string;
206
+ /**
207
+ * gh prints `0001-01-01T00:00:00Z`, Go's zero time, for a hit that is not
208
+ * closed. The parser reads that as `null`.
209
+ */
210
+ closedAt: string | null;
211
+ }
212
+ /**
213
+ * An open pull request #1 in `octo-org/hello-world`, as search returns it. Its
214
+ * `url` follows `number` and `repository`.
215
+ */
216
+ export declare function searchPrRow(overrides?: RowOverrides<GhSearchPrJson>): GhSearchPrJson;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `@osdlc/typed-gh/testing`: rows of `gh --json` output, for faking `gh` in
3
+ * tests.
4
+ *
5
+ * A separate subpath, not part of the package entry, so production code does
6
+ * not carry fixture data. Nothing in `src/` but this file knows about it.
7
+ *
8
+ * Each builder returns gh's raw JSON shape, what `gh … --json` prints
9
+ * (`nameWithOwner`, `headRefName`, `defaultBranchRef: { name }`), not the
10
+ * parsed model. Feed the rows to a fake runner through {@link ghResult} and
11
+ * the real parsers read them, so a test exercises the same parsing a real
12
+ * `gh` call does.
13
+ *
14
+ * The defaults fill every field the parsers require, which is where hand-made
15
+ * rows go wrong: a row the parser rejects is dropped without an error.
16
+ *
17
+ * - A pull request needs an integer `number` (`parsePullRequest`), and
18
+ * `prList({ head })` also keeps only rows whose `headRefName` equals
19
+ * `head`.
20
+ * - An issue and a search hit need a numeric `number`.
21
+ * - A repository needs `nameWithOwner` as `owner/name`, both parts
22
+ * non-empty (`parseRepository`).
23
+ * - A label needs a non-empty `name` (`parseLabels`).
24
+ * - A check needs a `name`, or a `context` for a legacy status context
25
+ * (`parseCheckRun`).
26
+ *
27
+ * Overrides merge shallowly: a field you pass replaces the default whole, and
28
+ * a field you pass as `undefined` is left out of the row, to test a payload
29
+ * that lacks it. A default that names another field (a pull request's `url`
30
+ * names its `number`) follows your override of that field. The default
31
+ * values may change in a minor release, as long as every parser still
32
+ * accepts them, so assert on what you set, not on a default.
33
+ */
34
+ /** `defaults` with `overrides` merged over them. An `undefined` override drops the field. */
35
+ function build(defaults, overrides) {
36
+ const row = { ...defaults };
37
+ for (const [key, value] of Object.entries(overrides)) {
38
+ if (value === undefined)
39
+ delete row[key];
40
+ else
41
+ row[key] = value;
42
+ }
43
+ return row;
44
+ }
45
+ const HOST = 'https://github.com';
46
+ const REPO = 'octo-org/hello-world';
47
+ const CREATED_AT = '2024-01-15T09:30:00Z';
48
+ const UPDATED_AT = '2024-01-16T14:05:00Z';
49
+ const HEAD_OID = '6dcb09b5b57875f334f61aebed695e2e4193db5e';
50
+ // ── the result ──────────────────────────────────────────────────────────────
51
+ /**
52
+ * A successful `gh` call that printed `payload` as JSON: exit code 0, the
53
+ * payload on stdout with gh's trailing newline, and nothing on stderr. Pass an
54
+ * array of rows for a list verb and one row for a view verb.
55
+ */
56
+ export function ghResult(payload) {
57
+ return { exitCode: 0, stdout: `${JSON.stringify(payload)}\n`, stderr: '' };
58
+ }
59
+ /** A new object each call, so a test that edits one row's author edits only that row. */
60
+ function octocat() {
61
+ return { id: 'MDQ6VXNlcjU4MzIzMQ==', is_bot: false, login: 'octocat', name: 'The Octocat' };
62
+ }
63
+ /** A label row. The default is GitHub's own `bug` label. */
64
+ export function labelRow(overrides = {}) {
65
+ return build({ name: 'bug', color: 'd73a4a', description: "Something isn't working" }, overrides);
66
+ }
67
+ /** A check run. The default completed with `SUCCESS`, so it reads as a green check. */
68
+ export function checkRunRow(overrides = {}) {
69
+ return build({
70
+ __typename: 'CheckRun',
71
+ name: 'test',
72
+ workflowName: 'CI',
73
+ status: 'COMPLETED',
74
+ conclusion: 'SUCCESS',
75
+ detailsUrl: `${HOST}/${REPO}/actions/runs/1/job/1`,
76
+ startedAt: CREATED_AT,
77
+ completedAt: UPDATED_AT,
78
+ }, overrides);
79
+ }
80
+ /** A legacy status context. The default is a `SUCCESS`. */
81
+ export function statusContextRow(overrides = {}) {
82
+ return build({
83
+ __typename: 'StatusContext',
84
+ context: 'ci/build',
85
+ state: 'SUCCESS',
86
+ targetUrl: 'https://ci.example.com/builds/1',
87
+ startedAt: CREATED_AT,
88
+ }, overrides);
89
+ }
90
+ /** A comment by `octocat`. */
91
+ export function commentRow(overrides = {}) {
92
+ return build({
93
+ id: 'IC_kwDOAbCdEf5xYz12',
94
+ author: { login: 'octocat' },
95
+ authorAssociation: 'MEMBER',
96
+ body: 'Looks good to me.',
97
+ createdAt: UPDATED_AT,
98
+ url: `${HOST}/${REPO}/pull/1#issuecomment-1`,
99
+ }, overrides);
100
+ }
101
+ /** An approving review by `octocat`. */
102
+ export function reviewRow(overrides = {}) {
103
+ return build({
104
+ id: 'PRR_kwDOAbCdEf5xYz34',
105
+ author: { login: 'octocat' },
106
+ authorAssociation: 'MEMBER',
107
+ body: '',
108
+ state: 'APPROVED',
109
+ submittedAt: UPDATED_AT,
110
+ commit: { oid: HEAD_OID },
111
+ }, overrides);
112
+ }
113
+ /**
114
+ * An open, mergeable pull request #1 from `feature/add-retries` into `main`
115
+ * of `octo-org/hello-world`, by `octocat`, with no labels, comments, reviews
116
+ * or checks. Its `url` follows `number`.
117
+ */
118
+ export function prRow(overrides = {}) {
119
+ const number = overrides.number ?? 1;
120
+ return build({
121
+ number,
122
+ state: 'OPEN',
123
+ title: 'Add retries to the fetch loop',
124
+ body: 'Retries a failed fetch three times before giving up.',
125
+ url: `${HOST}/${REPO}/pull/${number}`,
126
+ isDraft: false,
127
+ baseRefName: 'main',
128
+ headRefName: 'feature/add-retries',
129
+ headRefOid: HEAD_OID,
130
+ isCrossRepository: false,
131
+ mergeable: 'MERGEABLE',
132
+ mergeStateStatus: 'CLEAN',
133
+ reviewDecision: '',
134
+ author: octocat(),
135
+ labels: [],
136
+ comments: [],
137
+ reviews: [],
138
+ statusCheckRollup: [],
139
+ mergedAt: null,
140
+ createdAt: CREATED_AT,
141
+ updatedAt: UPDATED_AT,
142
+ }, overrides);
143
+ }
144
+ /** An open issue #1 with no labels. */
145
+ export function issueRow(overrides = {}) {
146
+ return build({
147
+ number: 1,
148
+ title: 'Fetch gives up after one failure',
149
+ body: 'A single network error ends the whole sync.',
150
+ labels: [],
151
+ state: 'OPEN',
152
+ }, overrides);
153
+ }
154
+ /** The public, unarchived `octo-org/hello-world` on `main`. Its `url` follows `nameWithOwner`. */
155
+ export function repoRow(overrides = {}) {
156
+ const nameWithOwner = overrides.nameWithOwner ?? REPO;
157
+ return build({
158
+ nameWithOwner,
159
+ description: 'My first repository on GitHub.',
160
+ defaultBranchRef: { name: 'main' },
161
+ visibility: 'PUBLIC',
162
+ isArchived: false,
163
+ url: `${HOST}/${nameWithOwner}`,
164
+ }, overrides);
165
+ }
166
+ /**
167
+ * An open pull request #1 in `octo-org/hello-world`, as search returns it. Its
168
+ * `url` follows `number` and `repository`.
169
+ */
170
+ export function searchPrRow(overrides = {}) {
171
+ const number = overrides.number ?? 1;
172
+ const repository = overrides.repository ?? { name: 'hello-world', nameWithOwner: REPO };
173
+ return build({
174
+ number,
175
+ title: 'Add retries to the fetch loop',
176
+ state: 'open',
177
+ url: `${HOST}/${repository.nameWithOwner}/pull/${number}`,
178
+ isDraft: false,
179
+ repository,
180
+ author: { id: 'MDQ6VXNlcjU4MzIzMQ==', is_bot: false, login: 'octocat' },
181
+ labels: [],
182
+ createdAt: CREATED_AT,
183
+ updatedAt: UPDATED_AT,
184
+ closedAt: '0001-01-01T00:00:00Z',
185
+ }, overrides);
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/typed-gh",
3
- "version": "0.11.1",
3
+ "version": "0.12.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",
@@ -10,6 +10,10 @@
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
+ },
14
+ "./testing": {
15
+ "types": "./dist/testing.d.ts",
16
+ "default": "./dist/testing.js"
13
17
  }
14
18
  },
15
19
  "files": [