@kitlangton/ghui 0.1.18 → 0.1.20

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,27 +1,26 @@
1
1
  import { Context, Effect, Layer, Schema } from "effect"
2
2
  import { config } from "../config.js"
3
- import { pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestQueueMode, type PullRequestReviewComment } 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
- const OptionalNullableString = Schema.optional(NullableString)
9
- const OptionalNullableNumber = Schema.optional(Schema.NullOr(Schema.Number))
8
+ const OptionalNullableString = Schema.optionalKey(NullableString)
9
+ const OptionalNullableNumber = Schema.optionalKey(Schema.NullOr(Schema.Number))
10
10
 
11
- const RawCheckRunSchema = Schema.Struct({
12
- __typename: Schema.Literal("CheckRun"),
13
- name: OptionalNullableString,
14
- status: OptionalNullableString,
15
- conclusion: OptionalNullableString,
16
- })
17
-
18
- const RawStatusContextSchema = Schema.Struct({
19
- __typename: Schema.Literal("StatusContext"),
20
- context: OptionalNullableString,
21
- state: OptionalNullableString,
22
- })
23
-
24
- const RawCheckContextSchema = Schema.Union([RawCheckRunSchema, RawStatusContextSchema])
11
+ const RawCheckContextSchema = Schema.Union([
12
+ Schema.Struct({
13
+ __typename: Schema.tag("CheckRun"),
14
+ name: OptionalNullableString,
15
+ status: OptionalNullableString,
16
+ conclusion: OptionalNullableString,
17
+ }),
18
+ Schema.Struct({
19
+ __typename: Schema.tag("StatusContext"),
20
+ context: OptionalNullableString,
21
+ state: OptionalNullableString,
22
+ }),
23
+ ]).pipe(Schema.toTaggedUnion("__typename"))
25
24
 
26
25
  const RawAuthorSchema = Schema.Struct({ login: Schema.String })
27
26
  const RawRepositorySchema = Schema.Struct({ nameWithOwner: Schema.String })
@@ -30,6 +29,10 @@ const RawLabelSchema = Schema.Struct({
30
29
  color: OptionalNullableString,
31
30
  })
32
31
 
32
+ const RawStatusCheckRollupSchema = Schema.Struct({
33
+ contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
34
+ })
35
+
33
36
  const RawPullRequestSummaryFields = {
34
37
  number: Schema.Number,
35
38
  title: Schema.String,
@@ -46,7 +49,10 @@ const RawPullRequestSummaryFields = {
46
49
  repository: RawRepositorySchema,
47
50
  } as const
48
51
 
49
- const RawPullRequestSummaryNodeSchema = Schema.Struct(RawPullRequestSummaryFields)
52
+ const RawPullRequestSummaryNodeSchema = Schema.Struct({
53
+ ...RawPullRequestSummaryFields,
54
+ statusCheckRollup: Schema.optionalKey(Schema.NullOr(RawStatusCheckRollupSchema)),
55
+ })
50
56
 
51
57
  const RawPullRequestNodeSchema = Schema.Struct({
52
58
  ...RawPullRequestSummaryFields,
@@ -55,9 +61,15 @@ const RawPullRequestNodeSchema = Schema.Struct({
55
61
  additions: Schema.Number,
56
62
  deletions: Schema.Number,
57
63
  changedFiles: Schema.Number,
58
- statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Struct({
59
- contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
60
- }))),
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
+ }),
61
73
  })
62
74
 
63
75
  const PageInfoSchema = Schema.Struct({
@@ -75,6 +87,17 @@ const SearchResponseSchema = <Item extends Schema.Top>(item: Item) =>
75
87
  }),
76
88
  })
77
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
+
78
101
  const ViewerSchema = Schema.Struct({ login: Schema.String })
79
102
 
80
103
  const MergeInfoResponseSchema = Schema.Struct({
@@ -88,22 +111,27 @@ const MergeInfoResponseSchema = Schema.Struct({
88
111
  statusCheckRollup: Schema.Array(RawCheckContextSchema),
89
112
  })
90
113
 
91
- const DiffCommentSideSchema = Schema.Union([Schema.Literal("LEFT"), Schema.Literal("RIGHT")])
92
-
93
114
  const PullRequestCommentSchema = Schema.Struct({
94
- id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
115
+ id: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
95
116
  node_id: OptionalNullableString,
96
117
  body: OptionalNullableString,
97
118
  html_url: OptionalNullableString,
98
119
  url: OptionalNullableString,
99
120
  created_at: OptionalNullableString,
100
- user: Schema.optional(Schema.NullOr(Schema.Struct({
121
+ user: Schema.optionalKey(Schema.NullOr(Schema.Struct({
101
122
  login: OptionalNullableString,
102
123
  }))),
103
124
  path: OptionalNullableString,
104
125
  line: OptionalNullableNumber,
105
126
  original_line: OptionalNullableNumber,
106
- side: Schema.optional(Schema.NullOr(DiffCommentSideSchema)),
127
+ side: Schema.optionalKey(Schema.NullOr(DiffCommentSide)),
128
+ })
129
+
130
+ const PullRequestFileSchema = Schema.Struct({
131
+ filename: Schema.String,
132
+ previous_filename: OptionalNullableString,
133
+ status: OptionalNullableString,
134
+ patch: OptionalNullableString,
107
135
  })
108
136
 
109
137
  const CommentsResponseSchema = Schema.Union([
@@ -111,6 +139,11 @@ const CommentsResponseSchema = Schema.Union([
111
139
  Schema.Array(Schema.Array(PullRequestCommentSchema)),
112
140
  ])
113
141
 
142
+ const PullRequestFilesResponseSchema = Schema.Union([
143
+ Schema.Array(PullRequestFileSchema),
144
+ Schema.Array(Schema.Array(PullRequestFileSchema)),
145
+ ])
146
+
114
147
  const RepoLabelsResponseSchema = Schema.Array(Schema.Struct({
115
148
  name: Schema.String,
116
149
  color: Schema.String,
@@ -120,6 +153,7 @@ type RawPullRequestSummaryNode = Schema.Schema.Type<typeof RawPullRequestSummary
120
153
  type RawPullRequestNode = Schema.Schema.Type<typeof RawPullRequestNodeSchema>
121
154
  type RawCheckContext = Schema.Schema.Type<typeof RawCheckContextSchema>
122
155
  type RawPullRequestComment = Schema.Schema.Type<typeof PullRequestCommentSchema>
156
+ type RawPullRequestFile = Schema.Schema.Type<typeof PullRequestFileSchema>
123
157
 
124
158
  type SearchResponse<Item> = {
125
159
  readonly data: {
@@ -133,11 +167,41 @@ type SearchResponse<Item> = {
133
167
  }
134
168
  }
135
169
 
136
- const pullRequestSearchQuery = `
137
- query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
138
- search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
139
- nodes {
140
- ... 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 = `
141
205
  number
142
206
  title
143
207
  body
@@ -155,16 +219,13 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
155
219
  author { login }
156
220
  headRefOid
157
221
  repository { nameWithOwner }
158
- labels(first: 20) { nodes { name color } }
159
- statusCheckRollup {
160
- contexts(first: 100) {
161
- nodes {
162
- __typename
163
- ... on CheckRun { name status conclusion }
164
- ... on StatusContext { context state }
165
- }
166
- }
167
- }
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}
168
229
  }
169
230
  }
170
231
  pageInfo { hasNextPage endCursor }
@@ -172,24 +233,20 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
172
233
  }
173
234
  `
174
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
+
175
245
  const pullRequestSummarySearchQuery = `
176
246
  query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
177
247
  search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
178
248
  nodes {
179
- ... on PullRequest {
180
- number
181
- title
182
- isDraft
183
- reviewDecision
184
- autoMergeRequest { enabledAt }
185
- state
186
- merged
187
- createdAt
188
- closedAt
189
- url
190
- author { login }
191
- headRefOid
192
- repository { nameWithOwner }
249
+ ... on PullRequest {${SUMMARY_FIELDS_FRAGMENT}
193
250
  }
194
251
  }
195
252
  pageInfo { hasNextPage endCursor }
@@ -197,6 +254,18 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
197
254
  }
198
255
  `
199
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
+
200
269
  const normalizeDate = (value: string | null | undefined) => {
201
270
  if (!value || value.startsWith("0001-01-01")) return null
202
271
  return new Date(value)
@@ -205,13 +274,13 @@ const normalizeDate = (value: string | null | undefined) => {
205
274
  const getPullRequestState = (item: { readonly state: string; readonly merged: boolean }): PullRequestItem["state"] =>
206
275
  item.merged ? "merged" : item.state.toLowerCase() === "open" ? "open" : "closed"
207
276
 
208
- const REVIEW_STATUS_BY_DECISION: Record<string, PullRequestItem["reviewStatus"]> = {
277
+ const REVIEW_STATUS_BY_DECISION: Record<string, ReviewStatus> = {
209
278
  APPROVED: "approved",
210
279
  CHANGES_REQUESTED: "changes",
211
280
  REVIEW_REQUIRED: "review",
212
281
  }
213
282
 
214
- const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): PullRequestItem["reviewStatus"] => {
283
+ const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): ReviewStatus => {
215
284
  if (item.isDraft) return "draft"
216
285
  if (item.reviewDecision) return REVIEW_STATUS_BY_DECISION[item.reviewDecision] ?? "none"
217
286
  return "none"
@@ -239,19 +308,24 @@ const normalizeCheckStatus = (raw: string | null | undefined): CheckItem["status
239
308
  const normalizeCheckConclusion = (raw: string | null | undefined): CheckItem["conclusion"] =>
240
309
  raw ? CHECK_CONCLUSION_BY_RAW[raw] ?? null : null
241
310
 
242
- const getContextStatus = (context: RawCheckContext): CheckItem["status"] => {
243
- if (context.__typename === "CheckRun") return normalizeCheckStatus(context.status)
244
- if (context.state === "PENDING") return "in_progress"
245
- return "completed"
246
- }
311
+ const getContextStatus = (context: RawCheckContext): CheckItem["status"] =>
312
+ RawCheckContextSchema.match(context, {
313
+ CheckRun: (run) => normalizeCheckStatus(run.status),
314
+ StatusContext: (status) => status.state === "PENDING" ? "in_progress" : "completed",
315
+ })
247
316
 
248
- const getContextConclusion = (context: RawCheckContext): CheckItem["conclusion"] => {
249
- if (context.__typename === "CheckRun") return normalizeCheckConclusion(context.conclusion)
250
- if (context.state === "SUCCESS") return "success"
251
- if (context.state === "FAILURE" || context.state === "ERROR") return "failure"
252
- return null
317
+ const STATUS_CONTEXT_CONCLUSION: Record<string, NonNullable<CheckItem["conclusion"]>> = {
318
+ SUCCESS: "success",
319
+ FAILURE: "failure",
320
+ ERROR: "failure",
253
321
  }
254
322
 
323
+ const getContextConclusion = (context: RawCheckContext): CheckItem["conclusion"] =>
324
+ RawCheckContextSchema.match(context, {
325
+ CheckRun: (run) => normalizeCheckConclusion(run.conclusion),
326
+ StatusContext: (status) => (status.state ? STATUS_CONTEXT_CONCLUSION[status.state] : null) ?? null,
327
+ })
328
+
255
329
  const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
256
330
  if (contexts.length === 0) {
257
331
  return { checkStatus: "none", checkSummary: null, checks: [] }
@@ -294,28 +368,31 @@ const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<Pu
294
368
  return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
295
369
  }
296
370
 
297
- const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestItem => ({
298
- repository: item.repository.nameWithOwner,
299
- author: item.author.login,
300
- headRefOid: item.headRefOid,
301
- number: item.number,
302
- title: item.title,
303
- body: "",
304
- labels: [],
305
- additions: 0,
306
- deletions: 0,
307
- changedFiles: 0,
308
- state: getPullRequestState(item),
309
- reviewStatus: getReviewStatus(item),
310
- checkStatus: "none",
311
- checkSummary: null,
312
- checks: [],
313
- autoMergeEnabled: item.autoMergeRequest !== null,
314
- detailLoaded: false,
315
- createdAt: new Date(item.createdAt),
316
- closedAt: normalizeDate(item.closedAt),
317
- url: item.url,
318
- })
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
+ }
319
396
 
320
397
  const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
321
398
  const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
@@ -336,10 +413,21 @@ const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
336
413
  }
337
414
  }
338
415
 
339
- 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
+ })
340
426
 
341
- const sortNewestFirst = (pullRequests: readonly PullRequestItem[]) =>
342
- [...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
+ }
343
431
 
344
432
  const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
345
433
  const line = comment.line ?? comment.original_line
@@ -357,15 +445,40 @@ const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestRev
357
445
  }
358
446
 
359
447
  const parsePullRequestComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestReviewComment[] => {
360
- const pages: readonly (readonly RawPullRequestComment[])[] = Array.isArray(response[0])
361
- ? response as readonly (readonly RawPullRequestComment[])[]
362
- : [response as readonly RawPullRequestComment[]]
363
- return pages.flatMap((page) => page.flatMap((comment) => {
448
+ return flattenSlurpedPages(response).flatMap((comment) => {
364
449
  const parsed = parsePullRequestComment(comment)
365
450
  return parsed ? [parsed] : []
366
- }))
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")
367
477
  }
368
478
 
479
+ export const pullRequestFilesToPatch = (files: readonly RawPullRequestFile[]) =>
480
+ files.map(fileHeaderPatch).join("\n")
481
+
369
482
  const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullRequestReviewComment => ({
370
483
  id: `created:${input.repository}:${input.number}:${input.path}:${input.side}:${input.line}:${Date.now()}`,
371
484
  path: input.path,
@@ -379,19 +492,21 @@ const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullReque
379
492
 
380
493
  export type GitHubError = CommandError | JsonParseError | Schema.SchemaError
381
494
 
382
- const MERGEABLE_BY_RAW: Record<string, PullRequestMergeInfo["mergeable"]> = {
495
+ const MERGEABLE_BY_RAW: Record<string, Mergeable> = {
383
496
  MERGEABLE: "mergeable",
384
497
  CONFLICTING: "conflicting",
385
498
  }
386
499
 
387
- const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] =>
500
+ const normalizeMergeable = (value: string): Mergeable =>
388
501
  MERGEABLE_BY_RAW[value] ?? "unknown"
389
502
 
390
503
  export class GitHubService extends Context.Service<GitHubService, {
391
- readonly listOpenPullRequests: (mode: PullRequestQueueMode) => Effect.Effect<readonly PullRequestItem[], GitHubError>
392
- 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>
393
508
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
394
- readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
509
+ readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, GitHubError>
395
510
  readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
396
511
  readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
397
512
  readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
@@ -407,55 +522,112 @@ export class GitHubService extends Context.Service<GitHubService, {
407
522
  Effect.gen(function*() {
408
523
  const command = yield* CommandRunner
409
524
 
410
- 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) => {
411
532
  const responseSchema = SearchResponseSchema(schema)
412
- return Effect.fn(`GitHubService.${label}`)(function*(mode: PullRequestQueueMode) {
413
- const pullRequests: PullRequestItem[] = []
414
- let cursor: string | null = null
415
-
416
- while (pullRequests.length < config.prFetchLimit) {
417
- const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
418
- const response: SearchResponse<Item["Type"]> = yield* command.runSchema(responseSchema, "gh", [
419
- "api", "graphql",
420
- "-f", `query=${query}`,
421
- "-F", `searchQuery=${searchQuery(mode, config.author)}`,
422
- "-F", `first=${pageSize}`,
423
- ...(cursor ? ["-F", `after=${cursor}`] : []),
424
- ])
425
-
426
- for (const node of response.data.search.nodes) {
427
- if (node) pullRequests.push(parse(node))
428
- }
429
-
430
- if (!response.data.search.pageInfo.hasNextPage) break
431
- cursor = response.data.search.pageInfo.endCursor
432
- if (!cursor) break
433
- }
434
-
435
- 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)
436
542
  })
437
543
  }
438
544
 
439
- const listOpenPullRequests = paginateSearch("listOpenPullRequests", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
440
- 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
441
580
 
442
- const getAuthenticatedUser = Effect.fn("GitHubService.getAuthenticatedUser")(function*() {
443
- const viewer = yield* command.runSchema(ViewerSchema, "gh", ["api", "user"])
444
- return viewer.login
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
+ }
587
+
588
+ return pullRequests
445
589
  })
446
590
 
447
- const getPullRequestDiff = Effect.fn("GitHubService.getPullRequestDiff")(function*(repository: string, number: number) {
448
- const result = yield* command.run("gh", ["pr", "diff", String(number), "--repo", repository, "--color", "never"])
449
- 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)
450
596
  })
451
597
 
452
- const listPullRequestComments = Effect.fn("GitHubService.listPullRequestComments")(function*(repository: string, number: number) {
453
- const response = yield* command.runSchema(CommentsResponseSchema, "gh", [
454
- "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}`,
455
610
  ])
456
- 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)
457
616
  })
458
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
+
459
631
  const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
460
632
  const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
461
633
  "pr", "view", String(number), "--repo", repository,
@@ -477,14 +649,11 @@ export class GitHubService extends Context.Service<GitHubService, {
477
649
  } satisfies PullRequestMergeInfo
478
650
  })
479
651
 
480
- const mergePullRequest = Effect.fn("GitHubService.mergePullRequest")(function*(repository: string, number: number, action: PullRequestMergeAction) {
481
- const base = ["pr", "merge", String(number), "--repo", repository] as const
482
- yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
483
- })
652
+ const mergePullRequest = (repository: string, number: number, action: PullRequestMergeAction) =>
653
+ ghVoid("mergePullRequest", ["pr", "merge", String(number), "--repo", repository, ...getMergeActionDefinition(action).cliArgs])
484
654
 
485
- const closePullRequest = Effect.fn("GitHubService.closePullRequest")(function*(repository: string, number: number) {
486
- yield* command.run("gh", ["pr", "close", String(number), "--repo", repository])
487
- })
655
+ const closePullRequest = (repository: string, number: number) =>
656
+ ghVoid("closePullRequest", ["pr", "close", String(number), "--repo", repository])
488
657
 
489
658
  const createPullRequestComment = Effect.fn("GitHubService.createPullRequestComment")(function*(input: CreatePullRequestCommentInput) {
490
659
  const response = yield* command.runSchema(PullRequestCommentSchema, "gh", [
@@ -498,28 +667,25 @@ export class GitHubService extends Context.Service<GitHubService, {
498
667
  return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
499
668
  })
500
669
 
501
- const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
502
- yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
503
- })
670
+ const toggleDraftStatus = (repository: string, number: number, isDraft: boolean) =>
671
+ ghVoid("toggleDraftStatus", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
504
672
 
505
- const listRepoLabels = Effect.fn("GitHubService.listRepoLabels")(function*(repository: string) {
506
- const labels = yield* command.runSchema(RepoLabelsResponseSchema, "gh", [
673
+ const listRepoLabels = (repository: string) =>
674
+ ghJson("listRepoLabels", RepoLabelsResponseSchema, [
507
675
  "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
508
- ])
509
- return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
510
- })
676
+ ]).pipe(Effect.map((labels) => labels.map((label) => ({ name: label.name, color: `#${label.color}` }))))
511
677
 
512
- const addPullRequestLabel = Effect.fn("GitHubService.addPullRequestLabel")(function*(repository: string, number: number, label: string) {
513
- yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
514
- })
678
+ const addPullRequestLabel = (repository: string, number: number, label: string) =>
679
+ ghVoid("addPullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
515
680
 
516
- const removePullRequestLabel = Effect.fn("GitHubService.removePullRequestLabel")(function*(repository: string, number: number, label: string) {
517
- yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
518
- })
681
+ const removePullRequestLabel = (repository: string, number: number, label: string) =>
682
+ ghVoid("removePullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
519
683
 
520
684
  return GitHubService.of({
521
685
  listOpenPullRequests,
686
+ listOpenPullRequestPage,
522
687
  listOpenPullRequestDetails,
688
+ getPullRequestDetails,
523
689
  getAuthenticatedUser,
524
690
  getPullRequestDiff,
525
691
  listPullRequestComments,