@kitlangton/ghui 0.1.19 → 0.1.21

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.
@@ -1,8 +1,8 @@
1
1
  import { Context, Effect, Layer, Schema } from "effect"
2
2
  import { config } from "../config.js"
3
- import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type Mergeable, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
3
+ import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
4
4
  import { getMergeActionDefinition } from "../mergeActions.js"
5
- import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
5
+ import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js"
6
6
 
7
7
  const NullableString = Schema.NullOr(Schema.String)
8
8
  const OptionalNullableString = Schema.optionalKey(NullableString)
@@ -29,6 +29,10 @@ const RawLabelSchema = Schema.Struct({
29
29
  color: OptionalNullableString,
30
30
  })
31
31
 
32
+ const RawStatusCheckRollupSchema = Schema.Struct({
33
+ contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
34
+ })
35
+
32
36
  const RawPullRequestSummaryFields = {
33
37
  number: Schema.Number,
34
38
  title: Schema.String,
@@ -45,7 +49,10 @@ const RawPullRequestSummaryFields = {
45
49
  repository: RawRepositorySchema,
46
50
  } as const
47
51
 
48
- const RawPullRequestSummaryNodeSchema = Schema.Struct(RawPullRequestSummaryFields)
52
+ const RawPullRequestSummaryNodeSchema = Schema.Struct({
53
+ ...RawPullRequestSummaryFields,
54
+ statusCheckRollup: Schema.optionalKey(Schema.NullOr(RawStatusCheckRollupSchema)),
55
+ })
49
56
 
50
57
  const RawPullRequestNodeSchema = Schema.Struct({
51
58
  ...RawPullRequestSummaryFields,
@@ -54,9 +61,15 @@ const RawPullRequestNodeSchema = Schema.Struct({
54
61
  additions: Schema.Number,
55
62
  deletions: Schema.Number,
56
63
  changedFiles: Schema.Number,
57
- statusCheckRollup: Schema.optionalKey(Schema.NullOr(Schema.Struct({
58
- contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
59
- }))),
64
+ statusCheckRollup: Schema.optionalKey(Schema.NullOr(RawStatusCheckRollupSchema)),
65
+ })
66
+
67
+ const PullRequestDetailResponseSchema = Schema.Struct({
68
+ data: Schema.Struct({
69
+ repository: Schema.NullOr(Schema.Struct({
70
+ pullRequest: Schema.NullOr(RawPullRequestNodeSchema),
71
+ })),
72
+ }),
60
73
  })
61
74
 
62
75
  const PageInfoSchema = Schema.Struct({
@@ -74,6 +87,17 @@ const SearchResponseSchema = <Item extends Schema.Top>(item: Item) =>
74
87
  }),
75
88
  })
76
89
 
90
+ const RepositoryPullRequestsResponseSchema = Schema.Struct({
91
+ data: Schema.Struct({
92
+ repository: Schema.NullOr(Schema.Struct({
93
+ pullRequests: Schema.Struct({
94
+ nodes: Schema.Array(Schema.NullOr(RawPullRequestSummaryNodeSchema)),
95
+ pageInfo: PageInfoSchema,
96
+ }),
97
+ })),
98
+ }),
99
+ })
100
+
77
101
  const ViewerSchema = Schema.Struct({ login: Schema.String })
78
102
 
79
103
  const MergeInfoResponseSchema = Schema.Struct({
@@ -103,11 +127,23 @@ const PullRequestCommentSchema = Schema.Struct({
103
127
  side: Schema.optionalKey(Schema.NullOr(DiffCommentSide)),
104
128
  })
105
129
 
130
+ const PullRequestFileSchema = Schema.Struct({
131
+ filename: Schema.String,
132
+ previous_filename: OptionalNullableString,
133
+ status: OptionalNullableString,
134
+ patch: OptionalNullableString,
135
+ })
136
+
106
137
  const CommentsResponseSchema = Schema.Union([
107
138
  Schema.Array(PullRequestCommentSchema),
108
139
  Schema.Array(Schema.Array(PullRequestCommentSchema)),
109
140
  ])
110
141
 
142
+ const PullRequestFilesResponseSchema = Schema.Union([
143
+ Schema.Array(PullRequestFileSchema),
144
+ Schema.Array(Schema.Array(PullRequestFileSchema)),
145
+ ])
146
+
111
147
  const RepoLabelsResponseSchema = Schema.Array(Schema.Struct({
112
148
  name: Schema.String,
113
149
  color: Schema.String,
@@ -117,6 +153,7 @@ type RawPullRequestSummaryNode = Schema.Schema.Type<typeof RawPullRequestSummary
117
153
  type RawPullRequestNode = Schema.Schema.Type<typeof RawPullRequestNodeSchema>
118
154
  type RawCheckContext = Schema.Schema.Type<typeof RawCheckContextSchema>
119
155
  type RawPullRequestComment = Schema.Schema.Type<typeof PullRequestCommentSchema>
156
+ type RawPullRequestFile = Schema.Schema.Type<typeof PullRequestFileSchema>
120
157
 
121
158
  type SearchResponse<Item> = {
122
159
  readonly data: {
@@ -130,11 +167,41 @@ type SearchResponse<Item> = {
130
167
  }
131
168
  }
132
169
 
133
- const pullRequestSearchQuery = `
134
- query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
135
- search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
136
- nodes {
137
- ... on PullRequest {
170
+ type PullRequestConnection<Item> = {
171
+ readonly nodes: readonly (Item | null)[]
172
+ readonly pageInfo: {
173
+ readonly hasNextPage: boolean
174
+ readonly endCursor: string | null
175
+ }
176
+ }
177
+
178
+ const STATUS_CHECK_FRAGMENT = `
179
+ statusCheckRollup {
180
+ contexts(first: 100) {
181
+ nodes {
182
+ __typename
183
+ ... on CheckRun { name status conclusion }
184
+ ... on StatusContext { context state }
185
+ }
186
+ }
187
+ }`
188
+
189
+ const SUMMARY_FIELDS_FRAGMENT = `
190
+ number
191
+ title
192
+ isDraft
193
+ reviewDecision
194
+ autoMergeRequest { enabledAt }
195
+ state
196
+ merged
197
+ createdAt
198
+ closedAt
199
+ url
200
+ author { login }
201
+ headRefOid
202
+ repository { nameWithOwner }${STATUS_CHECK_FRAGMENT}`
203
+
204
+ const DETAIL_FIELDS_FRAGMENT = `
138
205
  number
139
206
  title
140
207
  body
@@ -152,16 +219,13 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
152
219
  author { login }
153
220
  headRefOid
154
221
  repository { nameWithOwner }
155
- labels(first: 20) { nodes { name color } }
156
- statusCheckRollup {
157
- contexts(first: 100) {
158
- nodes {
159
- __typename
160
- ... on CheckRun { name status conclusion }
161
- ... on StatusContext { context state }
162
- }
163
- }
164
- }
222
+ labels(first: 20) { nodes { name color } }${STATUS_CHECK_FRAGMENT}`
223
+
224
+ const pullRequestSearchQuery = `
225
+ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
226
+ search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
227
+ nodes {
228
+ ... on PullRequest {${DETAIL_FIELDS_FRAGMENT}
165
229
  }
166
230
  }
167
231
  pageInfo { hasNextPage endCursor }
@@ -169,24 +233,20 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
169
233
  }
170
234
  `
171
235
 
236
+ const pullRequestDetailQuery = `
237
+ query PullRequest($owner: String!, $name: String!, $number: Int!) {
238
+ repository(owner: $owner, name: $name) {
239
+ pullRequest(number: $number) {${DETAIL_FIELDS_FRAGMENT}
240
+ }
241
+ }
242
+ }
243
+ `
244
+
172
245
  const pullRequestSummarySearchQuery = `
173
246
  query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
174
247
  search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
175
248
  nodes {
176
- ... on PullRequest {
177
- number
178
- title
179
- isDraft
180
- reviewDecision
181
- autoMergeRequest { enabledAt }
182
- state
183
- merged
184
- createdAt
185
- closedAt
186
- url
187
- author { login }
188
- headRefOid
189
- repository { nameWithOwner }
249
+ ... on PullRequest {${SUMMARY_FIELDS_FRAGMENT}
190
250
  }
191
251
  }
192
252
  pageInfo { hasNextPage endCursor }
@@ -194,6 +254,18 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
194
254
  }
195
255
  `
196
256
 
257
+ const repositoryPullRequestsQuery = `
258
+ query RepositoryPullRequests($owner: String!, $name: String!, $first: Int!, $after: String) {
259
+ repository(owner: $owner, name: $name) {
260
+ pullRequests(states: OPEN, first: $first, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
261
+ nodes {${SUMMARY_FIELDS_FRAGMENT}
262
+ }
263
+ pageInfo { hasNextPage endCursor }
264
+ }
265
+ }
266
+ }
267
+ `
268
+
197
269
  const normalizeDate = (value: string | null | undefined) => {
198
270
  if (!value || value.startsWith("0001-01-01")) return null
199
271
  return new Date(value)
@@ -296,28 +368,31 @@ const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<Pu
296
368
  return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
297
369
  }
298
370
 
299
- const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestItem => ({
300
- repository: item.repository.nameWithOwner,
301
- author: item.author.login,
302
- headRefOid: item.headRefOid,
303
- number: item.number,
304
- title: item.title,
305
- body: "",
306
- labels: [],
307
- additions: 0,
308
- deletions: 0,
309
- changedFiles: 0,
310
- state: getPullRequestState(item),
311
- reviewStatus: getReviewStatus(item),
312
- checkStatus: "none",
313
- checkSummary: null,
314
- checks: [],
315
- autoMergeEnabled: item.autoMergeRequest !== null,
316
- detailLoaded: false,
317
- createdAt: new Date(item.createdAt),
318
- closedAt: normalizeDate(item.closedAt),
319
- url: item.url,
320
- })
371
+ const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestItem => {
372
+ const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
373
+ return {
374
+ repository: item.repository.nameWithOwner,
375
+ author: item.author.login,
376
+ headRefOid: item.headRefOid,
377
+ number: item.number,
378
+ title: item.title,
379
+ body: "",
380
+ labels: [],
381
+ additions: 0,
382
+ deletions: 0,
383
+ changedFiles: 0,
384
+ state: getPullRequestState(item),
385
+ reviewStatus: getReviewStatus(item),
386
+ checkStatus: checkInfo.checkStatus,
387
+ checkSummary: checkInfo.checkSummary,
388
+ checks: checkInfo.checks,
389
+ autoMergeEnabled: item.autoMergeRequest !== null,
390
+ detailLoaded: false,
391
+ createdAt: new Date(item.createdAt),
392
+ closedAt: normalizeDate(item.closedAt),
393
+ url: item.url,
394
+ }
395
+ }
321
396
 
322
397
  const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
323
398
  const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
@@ -338,10 +413,21 @@ const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
338
413
  }
339
414
  }
340
415
 
341
- const searchQuery = (mode: PullRequestQueueMode, author: string) => `${pullRequestQueueSearchQualifier(mode, author)} is:pr is:open sort:created-desc`
416
+ const searchQuery = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
417
+ const sort = mode === "repository" ? "sort:updated-desc" : "sort:created-desc"
418
+ return `${pullRequestQueueSearchQualifier(mode, author, repository)} is:pr is:open ${sort}`
419
+ }
420
+
421
+ const pullRequestPage = <Item>(connection: PullRequestConnection<Item>, parse: (node: Item) => PullRequestItem): PullRequestPage => ({
422
+ items: connection.nodes.flatMap((node) => node ? [parse(node)] : []),
423
+ endCursor: connection.pageInfo.endCursor,
424
+ hasNextPage: connection.pageInfo.hasNextPage && connection.pageInfo.endCursor !== null,
425
+ })
342
426
 
343
- const sortNewestFirst = (pullRequests: readonly PullRequestItem[]) =>
344
- [...pullRequests].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
427
+ const repositoryParts = (repository: string) => {
428
+ const [owner, name] = repository.split("/")
429
+ return owner && name ? { owner, name } : null
430
+ }
345
431
 
346
432
  const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
347
433
  const line = comment.line ?? comment.original_line
@@ -359,15 +445,40 @@ const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestRev
359
445
  }
360
446
 
361
447
  const parsePullRequestComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestReviewComment[] => {
362
- const pages: readonly (readonly RawPullRequestComment[])[] = Array.isArray(response[0])
363
- ? response as readonly (readonly RawPullRequestComment[])[]
364
- : [response as readonly RawPullRequestComment[]]
365
- return pages.flatMap((page) => page.flatMap((comment) => {
448
+ return flattenSlurpedPages(response).flatMap((comment) => {
366
449
  const parsed = parsePullRequestComment(comment)
367
450
  return parsed ? [parsed] : []
368
- }))
451
+ })
452
+ }
453
+
454
+ const flattenSlurpedPages = <Item>(response: readonly Item[] | readonly (readonly Item[])[]): readonly Item[] =>
455
+ Array.isArray(response[0]) ? (response as readonly (readonly Item[])[]).flat() : response as readonly Item[]
456
+
457
+ const parsePullRequestFiles = (response: Schema.Schema.Type<typeof PullRequestFilesResponseSchema>): readonly RawPullRequestFile[] =>
458
+ flattenSlurpedPages(response)
459
+
460
+ const diffPath = (path: string) => /\s|"/.test(path) ? JSON.stringify(path) : path
461
+
462
+ const prefixedDiffPath = (prefix: "a" | "b", path: string) => diffPath(`${prefix}/${path}`)
463
+
464
+ const fileHeaderPatch = (file: RawPullRequestFile) => {
465
+ const oldPath = file.previous_filename ?? file.filename
466
+ const newPath = file.filename
467
+ const oldRef = file.status === "added" ? "/dev/null" : prefixedDiffPath("a", oldPath)
468
+ const newRef = file.status === "removed" ? "/dev/null" : prefixedDiffPath("b", newPath)
469
+ const lines = [
470
+ `diff --git ${prefixedDiffPath("a", oldPath)} ${prefixedDiffPath("b", newPath)}`,
471
+ ...(file.status === "renamed" && file.previous_filename ? [`rename from ${oldPath}`, `rename to ${newPath}`] : []),
472
+ `--- ${oldRef}`,
473
+ `+++ ${newRef}`,
474
+ ]
475
+ if (file.patch) lines.push(file.patch.trimEnd())
476
+ return lines.join("\n")
369
477
  }
370
478
 
479
+ export const pullRequestFilesToPatch = (files: readonly RawPullRequestFile[]) =>
480
+ files.map(fileHeaderPatch).join("\n")
481
+
371
482
  const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullRequestReviewComment => ({
372
483
  id: `created:${input.repository}:${input.number}:${input.path}:${input.side}:${input.line}:${Date.now()}`,
373
484
  path: input.path,
@@ -390,10 +501,12 @@ const normalizeMergeable = (value: string): Mergeable =>
390
501
  MERGEABLE_BY_RAW[value] ?? "unknown"
391
502
 
392
503
  export class GitHubService extends Context.Service<GitHubService, {
393
- readonly listOpenPullRequests: (mode: PullRequestQueueMode) => Effect.Effect<readonly PullRequestItem[], GitHubError>
394
- readonly listOpenPullRequestDetails: (mode: PullRequestQueueMode) => Effect.Effect<readonly PullRequestItem[], GitHubError>
504
+ readonly listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
505
+ readonly listOpenPullRequestPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>
506
+ readonly listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
507
+ readonly getPullRequestDetails: (repository: string, number: number) => Effect.Effect<PullRequestItem, GitHubError>
395
508
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
396
- readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
509
+ readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, GitHubError>
397
510
  readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
398
511
  readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
399
512
  readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
@@ -409,55 +522,112 @@ export class GitHubService extends Context.Service<GitHubService, {
409
522
  Effect.gen(function*() {
410
523
  const command = yield* CommandRunner
411
524
 
412
- const paginateSearch = <Item extends Schema.Top>(label: string, query: string, schema: Item, parse: (node: Item["Type"]) => PullRequestItem) => {
525
+ const ghJson = <S extends Schema.Top>(label: string, schema: S, args: readonly string[]) =>
526
+ command.runSchema(schema, "gh", args).pipe(Effect.withSpan(`GitHubService.${label}`))
527
+
528
+ const ghVoid = (label: string, args: readonly string[]) =>
529
+ command.run("gh", args).pipe(Effect.withSpan(`GitHubService.${label}`), Effect.asVoid)
530
+
531
+ const searchPage = <Item extends Schema.Top>(label: string, query: string, schema: Item, parse: (node: Item["Type"]) => PullRequestItem) => {
413
532
  const responseSchema = SearchResponseSchema(schema)
414
- return Effect.fn(`GitHubService.${label}`)(function*(mode: PullRequestQueueMode) {
415
- const pullRequests: PullRequestItem[] = []
416
- let cursor: string | null = null
417
-
418
- while (pullRequests.length < config.prFetchLimit) {
419
- const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
420
- const response: SearchResponse<Item["Type"]> = yield* command.runSchema(responseSchema, "gh", [
421
- "api", "graphql",
422
- "-f", `query=${query}`,
423
- "-F", `searchQuery=${searchQuery(mode, config.author)}`,
424
- "-F", `first=${pageSize}`,
425
- ...(cursor ? ["-F", `after=${cursor}`] : []),
426
- ])
427
-
428
- for (const node of response.data.search.nodes) {
429
- if (node) pullRequests.push(parse(node))
430
- }
431
-
432
- if (!response.data.search.pageInfo.hasNextPage) break
433
- cursor = response.data.search.pageInfo.endCursor
434
- if (!cursor) break
435
- }
436
-
437
- return sortNewestFirst(pullRequests)
533
+ return Effect.fn(`GitHubService.${label}`)(function*(input: ListPullRequestPageInput) {
534
+ const response: SearchResponse<Item["Type"]> = yield* command.runSchema(responseSchema, "gh", [
535
+ "api", "graphql",
536
+ "-f", `query=${query}`,
537
+ "-F", `searchQuery=${searchQuery(input.mode, config.author, input.repository)}`,
538
+ "-F", `first=${input.pageSize}`,
539
+ ...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
540
+ ])
541
+ return pullRequestPage(response.data.search, parse)
438
542
  })
439
543
  }
440
544
 
441
- const listOpenPullRequests = paginateSearch("listOpenPullRequests", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
442
- const listOpenPullRequestDetails = paginateSearch("listOpenPullRequestDetails", pullRequestSearchQuery, RawPullRequestNodeSchema, parsePullRequest)
545
+ const listOpenPullRequestSearchPage = searchPage("listOpenPullRequestSearchPage", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
546
+ const listOpenPullRequestDetailsPage = searchPage("listOpenPullRequestDetailsPage", pullRequestSearchQuery, RawPullRequestNodeSchema, parsePullRequest)
547
+
548
+ const listRepositoryPullRequestPage = Effect.fn("GitHubService.listRepositoryPullRequestPage")(function*(input: ListPullRequestPageInput) {
549
+ if (!input.repository) return { items: [], endCursor: null, hasNextPage: false } satisfies PullRequestPage
550
+ const repo = repositoryParts(input.repository)
551
+ if (!repo) {
552
+ return yield* new CommandError({ command: "gh", args: [], detail: `Invalid repository: ${input.repository}`, cause: input.repository })
553
+ }
554
+
555
+ const response = yield* command.runSchema(RepositoryPullRequestsResponseSchema, "gh", [
556
+ "api", "graphql",
557
+ "-f", `query=${repositoryPullRequestsQuery}`,
558
+ "-F", `owner=${repo.owner}`,
559
+ "-F", `name=${repo.name}`,
560
+ "-F", `first=${input.pageSize}`,
561
+ ...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
562
+ ])
563
+ const connection = response.data.repository?.pullRequests
564
+ if (!connection) {
565
+ return yield* new CommandError({ command: "gh", args: [], detail: `Repository not found: ${input.repository}`, cause: input.repository })
566
+ }
567
+ return pullRequestPage(connection, parsePullRequestSummary)
568
+ })
569
+
570
+ const listOpenPullRequestPage = Effect.fn("GitHubService.listOpenPullRequestPage")(function*(input: ListPullRequestPageInput) {
571
+ const pageSize = Math.max(1, Math.min(100, input.pageSize))
572
+ const pageInput = { ...input, pageSize }
573
+ if (pageInput.mode === "repository" && pageInput.repository) return yield* listRepositoryPullRequestPage(pageInput)
574
+ return yield* listOpenPullRequestSearchPage(pageInput)
575
+ })
576
+
577
+ const paginatePages = Effect.fn("GitHubService.paginatePages")(function*(mode: PullRequestQueueMode, repository: string | null, loadPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>) {
578
+ const pullRequests: PullRequestItem[] = []
579
+ let cursor: string | null = null
580
+
581
+ while (pullRequests.length < config.prFetchLimit) {
582
+ const page: PullRequestPage = yield* loadPage({ mode, repository, cursor, pageSize: Math.min(100, config.prFetchLimit - pullRequests.length) })
583
+ pullRequests.push(...page.items)
584
+ if (!page.hasNextPage || !page.endCursor) break
585
+ cursor = page.endCursor
586
+ }
443
587
 
444
- const getAuthenticatedUser = Effect.fn("GitHubService.getAuthenticatedUser")(function*() {
445
- const viewer = yield* command.runSchema(ViewerSchema, "gh", ["api", "user"])
446
- return viewer.login
588
+ return pullRequests
447
589
  })
448
590
 
449
- const getPullRequestDiff = Effect.fn("GitHubService.getPullRequestDiff")(function*(repository: string, number: number) {
450
- const result = yield* command.run("gh", ["pr", "diff", String(number), "--repo", repository, "--color", "never"])
451
- return result.stdout
591
+ const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*(mode: PullRequestQueueMode, repository: string | null) {
592
+ return yield* paginatePages(mode, repository, listOpenPullRequestPage)
593
+ })
594
+ const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function*(mode: PullRequestQueueMode, repository: string | null) {
595
+ return yield* paginatePages(mode, repository, listOpenPullRequestDetailsPage)
452
596
  })
453
597
 
454
- const listPullRequestComments = Effect.fn("GitHubService.listPullRequestComments")(function*(repository: string, number: number) {
455
- const response = yield* command.runSchema(CommentsResponseSchema, "gh", [
456
- "api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/comments`,
598
+ const getPullRequestDetails = Effect.fn("GitHubService.getPullRequestDetails")(function*(repository: string, number: number) {
599
+ const repo = repositoryParts(repository)
600
+ if (!repo) {
601
+ return yield* new CommandError({ command: "gh", args: [], detail: `Invalid repository: ${repository}`, cause: repository })
602
+ }
603
+
604
+ const response = yield* command.runSchema(PullRequestDetailResponseSchema, "gh", [
605
+ "api", "graphql",
606
+ "-f", `query=${pullRequestDetailQuery}`,
607
+ "-F", `owner=${repo.owner}`,
608
+ "-F", `name=${repo.name}`,
609
+ "-F", `number=${number}`,
457
610
  ])
458
- return parsePullRequestComments(response)
611
+ const pullRequest = response.data.repository?.pullRequest
612
+ if (!pullRequest) {
613
+ return yield* new CommandError({ command: "gh", args: [], detail: `Pull request not found: ${repository}#${number}`, cause: `${repository}#${number}` })
614
+ }
615
+ return parsePullRequest(pullRequest)
459
616
  })
460
617
 
618
+ const getAuthenticatedUser = () =>
619
+ ghJson("getAuthenticatedUser", ViewerSchema, ["api", "user"]).pipe(Effect.map((viewer) => viewer.login))
620
+
621
+ const getPullRequestDiff = (repository: string, number: number) =>
622
+ ghJson("getPullRequestDiff", PullRequestFilesResponseSchema, [
623
+ "api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/files`,
624
+ ]).pipe(Effect.map((response) => pullRequestFilesToPatch(parsePullRequestFiles(response))))
625
+
626
+ const listPullRequestComments = (repository: string, number: number) =>
627
+ ghJson("listPullRequestComments", CommentsResponseSchema, [
628
+ "api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/comments`,
629
+ ]).pipe(Effect.map(parsePullRequestComments))
630
+
461
631
  const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
462
632
  const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
463
633
  "pr", "view", String(number), "--repo", repository,
@@ -479,14 +649,11 @@ export class GitHubService extends Context.Service<GitHubService, {
479
649
  } satisfies PullRequestMergeInfo
480
650
  })
481
651
 
482
- const mergePullRequest = Effect.fn("GitHubService.mergePullRequest")(function*(repository: string, number: number, action: PullRequestMergeAction) {
483
- const base = ["pr", "merge", String(number), "--repo", repository] as const
484
- yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
485
- })
652
+ const mergePullRequest = (repository: string, number: number, action: PullRequestMergeAction) =>
653
+ ghVoid("mergePullRequest", ["pr", "merge", String(number), "--repo", repository, ...getMergeActionDefinition(action).cliArgs])
486
654
 
487
- const closePullRequest = Effect.fn("GitHubService.closePullRequest")(function*(repository: string, number: number) {
488
- yield* command.run("gh", ["pr", "close", String(number), "--repo", repository])
489
- })
655
+ const closePullRequest = (repository: string, number: number) =>
656
+ ghVoid("closePullRequest", ["pr", "close", String(number), "--repo", repository])
490
657
 
491
658
  const createPullRequestComment = Effect.fn("GitHubService.createPullRequestComment")(function*(input: CreatePullRequestCommentInput) {
492
659
  const response = yield* command.runSchema(PullRequestCommentSchema, "gh", [
@@ -500,28 +667,25 @@ export class GitHubService extends Context.Service<GitHubService, {
500
667
  return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
501
668
  })
502
669
 
503
- const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
504
- yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
505
- })
670
+ const toggleDraftStatus = (repository: string, number: number, isDraft: boolean) =>
671
+ ghVoid("toggleDraftStatus", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
506
672
 
507
- const listRepoLabels = Effect.fn("GitHubService.listRepoLabels")(function*(repository: string) {
508
- const labels = yield* command.runSchema(RepoLabelsResponseSchema, "gh", [
673
+ const listRepoLabels = (repository: string) =>
674
+ ghJson("listRepoLabels", RepoLabelsResponseSchema, [
509
675
  "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
510
- ])
511
- return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
512
- })
676
+ ]).pipe(Effect.map((labels) => labels.map((label) => ({ name: label.name, color: `#${label.color}` }))))
513
677
 
514
- const addPullRequestLabel = Effect.fn("GitHubService.addPullRequestLabel")(function*(repository: string, number: number, label: string) {
515
- yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
516
- })
678
+ const addPullRequestLabel = (repository: string, number: number, label: string) =>
679
+ ghVoid("addPullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
517
680
 
518
- const removePullRequestLabel = Effect.fn("GitHubService.removePullRequestLabel")(function*(repository: string, number: number, label: string) {
519
- yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
520
- })
681
+ const removePullRequestLabel = (repository: string, number: number, label: string) =>
682
+ ghVoid("removePullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
521
683
 
522
684
  return GitHubService.of({
523
685
  listOpenPullRequests,
686
+ listOpenPullRequestPage,
524
687
  listOpenPullRequestDetails,
688
+ getPullRequestDetails,
525
689
  getAuthenticatedUser,
526
690
  getPullRequestDiff,
527
691
  listPullRequestComments,