@kitlangton/ghui 0.1.16 → 0.1.18

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,71 +1,130 @@
1
- import { Context, Effect, Layer } from "effect"
1
+ import { Context, Effect, Layer, Schema } from "effect"
2
2
  import { config } from "../config.js"
3
- import type { CheckItem, PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
3
+ import { pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestQueueMode, type PullRequestReviewComment } from "../domain.js"
4
4
  import { getMergeActionDefinition } from "../mergeActions.js"
5
5
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
6
6
 
7
- interface GitHubPullRequestSummaryNode {
8
- readonly number: number
9
- readonly title: string
10
- readonly isDraft: boolean
11
- readonly reviewDecision: string | null
12
- readonly autoMergeRequest: unknown | null
13
- readonly state: string
14
- readonly createdAt: string
15
- readonly closedAt?: string | null
16
- readonly url: string
17
- readonly repository: {
18
- readonly nameWithOwner: string
19
- }
20
- }
7
+ const NullableString = Schema.NullOr(Schema.String)
8
+ const OptionalNullableString = Schema.optional(NullableString)
9
+ const OptionalNullableNumber = Schema.optional(Schema.NullOr(Schema.Number))
21
10
 
22
- interface GitHubPullRequestNode extends GitHubPullRequestSummaryNode {
23
- readonly body: string
24
- readonly labels: {
25
- readonly nodes: readonly {
26
- readonly name: string
27
- readonly color?: string | null
28
- }[]
29
- }
30
- readonly additions: number
31
- readonly deletions: number
32
- readonly changedFiles: number
33
- readonly statusCheckRollup?: {
34
- readonly contexts: {
35
- readonly nodes: readonly GraphQLCheckContext[]
36
- }
37
- } | null
38
- }
11
+ const RawCheckRunSchema = Schema.Struct({
12
+ __typename: Schema.Literal("CheckRun"),
13
+ name: OptionalNullableString,
14
+ status: OptionalNullableString,
15
+ conclusion: OptionalNullableString,
16
+ })
39
17
 
40
- type GraphQLCheckContext =
41
- | {
42
- readonly __typename: "CheckRun"
43
- readonly name?: string | null
44
- readonly status?: string | null
45
- readonly conclusion?: string | null
46
- }
47
- | {
48
- readonly __typename: "StatusContext"
49
- readonly context?: string | null
50
- readonly state?: string | null
51
- }
18
+ const RawStatusContextSchema = Schema.Struct({
19
+ __typename: Schema.Literal("StatusContext"),
20
+ context: OptionalNullableString,
21
+ state: OptionalNullableString,
22
+ })
52
23
 
53
- interface GraphQLSearchResponse {
54
- readonly data: {
55
- readonly search: {
56
- readonly nodes: readonly (GitHubPullRequestNode | null)[]
57
- readonly pageInfo: {
58
- readonly hasNextPage: boolean
59
- readonly endCursor: string | null
60
- }
61
- }
62
- }
63
- }
24
+ const RawCheckContextSchema = Schema.Union([RawCheckRunSchema, RawStatusContextSchema])
25
+
26
+ const RawAuthorSchema = Schema.Struct({ login: Schema.String })
27
+ const RawRepositorySchema = Schema.Struct({ nameWithOwner: Schema.String })
28
+ const RawLabelSchema = Schema.Struct({
29
+ name: Schema.String,
30
+ color: OptionalNullableString,
31
+ })
64
32
 
65
- interface GraphQLSearchSummaryResponse {
33
+ const RawPullRequestSummaryFields = {
34
+ number: Schema.Number,
35
+ title: Schema.String,
36
+ isDraft: Schema.Boolean,
37
+ reviewDecision: NullableString,
38
+ autoMergeRequest: Schema.NullOr(Schema.Unknown),
39
+ state: Schema.String,
40
+ merged: Schema.Boolean,
41
+ createdAt: Schema.String,
42
+ closedAt: OptionalNullableString,
43
+ url: Schema.String,
44
+ author: RawAuthorSchema,
45
+ headRefOid: Schema.String,
46
+ repository: RawRepositorySchema,
47
+ } as const
48
+
49
+ const RawPullRequestSummaryNodeSchema = Schema.Struct(RawPullRequestSummaryFields)
50
+
51
+ const RawPullRequestNodeSchema = Schema.Struct({
52
+ ...RawPullRequestSummaryFields,
53
+ body: Schema.String,
54
+ labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }),
55
+ additions: Schema.Number,
56
+ deletions: Schema.Number,
57
+ changedFiles: Schema.Number,
58
+ statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Struct({
59
+ contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
60
+ }))),
61
+ })
62
+
63
+ const PageInfoSchema = Schema.Struct({
64
+ hasNextPage: Schema.Boolean,
65
+ endCursor: NullableString,
66
+ })
67
+
68
+ const SearchResponseSchema = <Item extends Schema.Top>(item: Item) =>
69
+ Schema.Struct({
70
+ data: Schema.Struct({
71
+ search: Schema.Struct({
72
+ nodes: Schema.Array(Schema.NullOr(item)),
73
+ pageInfo: PageInfoSchema,
74
+ }),
75
+ }),
76
+ })
77
+
78
+ const ViewerSchema = Schema.Struct({ login: Schema.String })
79
+
80
+ const MergeInfoResponseSchema = Schema.Struct({
81
+ number: Schema.Number,
82
+ title: Schema.String,
83
+ state: Schema.String,
84
+ isDraft: Schema.Boolean,
85
+ mergeable: Schema.String,
86
+ reviewDecision: NullableString,
87
+ autoMergeRequest: Schema.NullOr(Schema.Unknown),
88
+ statusCheckRollup: Schema.Array(RawCheckContextSchema),
89
+ })
90
+
91
+ const DiffCommentSideSchema = Schema.Union([Schema.Literal("LEFT"), Schema.Literal("RIGHT")])
92
+
93
+ const PullRequestCommentSchema = Schema.Struct({
94
+ id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
95
+ node_id: OptionalNullableString,
96
+ body: OptionalNullableString,
97
+ html_url: OptionalNullableString,
98
+ url: OptionalNullableString,
99
+ created_at: OptionalNullableString,
100
+ user: Schema.optional(Schema.NullOr(Schema.Struct({
101
+ login: OptionalNullableString,
102
+ }))),
103
+ path: OptionalNullableString,
104
+ line: OptionalNullableNumber,
105
+ original_line: OptionalNullableNumber,
106
+ side: Schema.optional(Schema.NullOr(DiffCommentSideSchema)),
107
+ })
108
+
109
+ const CommentsResponseSchema = Schema.Union([
110
+ Schema.Array(PullRequestCommentSchema),
111
+ Schema.Array(Schema.Array(PullRequestCommentSchema)),
112
+ ])
113
+
114
+ const RepoLabelsResponseSchema = Schema.Array(Schema.Struct({
115
+ name: Schema.String,
116
+ color: Schema.String,
117
+ }))
118
+
119
+ type RawPullRequestSummaryNode = Schema.Schema.Type<typeof RawPullRequestSummaryNodeSchema>
120
+ type RawPullRequestNode = Schema.Schema.Type<typeof RawPullRequestNodeSchema>
121
+ type RawCheckContext = Schema.Schema.Type<typeof RawCheckContextSchema>
122
+ type RawPullRequestComment = Schema.Schema.Type<typeof PullRequestCommentSchema>
123
+
124
+ type SearchResponse<Item> = {
66
125
  readonly data: {
67
126
  readonly search: {
68
- readonly nodes: readonly (GitHubPullRequestSummaryNode | null)[]
127
+ readonly nodes: readonly (Item | null)[]
69
128
  readonly pageInfo: {
70
129
  readonly hasNextPage: boolean
71
130
  readonly endCursor: string | null
@@ -74,21 +133,6 @@ interface GraphQLSearchSummaryResponse {
74
133
  }
75
134
  }
76
135
 
77
- interface GitHubViewer {
78
- readonly login: string
79
- }
80
-
81
- interface GitHubMergeInfoResponse {
82
- readonly number: number
83
- readonly title: string
84
- readonly state: string
85
- readonly isDraft: boolean
86
- readonly mergeable: string
87
- readonly reviewDecision: string | null
88
- readonly autoMergeRequest: unknown | null
89
- readonly statusCheckRollup: readonly GraphQLCheckContext[]
90
- }
91
-
92
136
  const pullRequestSearchQuery = `
93
137
  query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
94
138
  search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
@@ -104,9 +148,12 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
104
148
  deletions
105
149
  changedFiles
106
150
  state
151
+ merged
107
152
  createdAt
108
153
  closedAt
109
154
  url
155
+ author { login }
156
+ headRefOid
110
157
  repository { nameWithOwner }
111
158
  labels(first: 20) { nodes { name color } }
112
159
  statusCheckRollup {
@@ -136,9 +183,12 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
136
183
  reviewDecision
137
184
  autoMergeRequest { enabledAt }
138
185
  state
186
+ merged
139
187
  createdAt
140
188
  closedAt
141
189
  url
190
+ author { login }
191
+ headRefOid
142
192
  repository { nameWithOwner }
143
193
  }
144
194
  }
@@ -152,45 +202,57 @@ const normalizeDate = (value: string | null | undefined) => {
152
202
  return new Date(value)
153
203
  }
154
204
 
205
+ const getPullRequestState = (item: { readonly state: string; readonly merged: boolean }): PullRequestItem["state"] =>
206
+ item.merged ? "merged" : item.state.toLowerCase() === "open" ? "open" : "closed"
207
+
208
+ const REVIEW_STATUS_BY_DECISION: Record<string, PullRequestItem["reviewStatus"]> = {
209
+ APPROVED: "approved",
210
+ CHANGES_REQUESTED: "changes",
211
+ REVIEW_REQUIRED: "review",
212
+ }
213
+
155
214
  const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): PullRequestItem["reviewStatus"] => {
156
215
  if (item.isDraft) return "draft"
157
- if (item.reviewDecision === "APPROVED") return "approved"
158
- if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
159
- if (item.reviewDecision === "REVIEW_REQUIRED") return "review"
216
+ if (item.reviewDecision) return REVIEW_STATUS_BY_DECISION[item.reviewDecision] ?? "none"
160
217
  return "none"
161
218
  }
162
219
 
163
- const normalizeCheckStatus = (raw?: string | null): CheckItem["status"] => {
164
- if (raw === "COMPLETED") return "completed"
165
- if (raw === "IN_PROGRESS") return "in_progress"
166
- if (raw === "QUEUED") return "queued"
167
- return "pending"
220
+ const CHECK_STATUS_BY_RAW: Record<string, CheckItem["status"]> = {
221
+ COMPLETED: "completed",
222
+ IN_PROGRESS: "in_progress",
223
+ QUEUED: "queued",
168
224
  }
169
225
 
170
- const normalizeCheckConclusion = (raw?: string | null): CheckItem["conclusion"] => {
171
- if (raw === "SUCCESS") return "success"
172
- if (raw === "FAILURE" || raw === "ERROR") return "failure"
173
- if (raw === "NEUTRAL") return "neutral"
174
- if (raw === "SKIPPED") return "skipped"
175
- if (raw === "CANCELLED") return "cancelled"
176
- if (raw === "TIMED_OUT") return "timed_out"
177
- return null
226
+ const CHECK_CONCLUSION_BY_RAW: Record<string, NonNullable<CheckItem["conclusion"]>> = {
227
+ SUCCESS: "success",
228
+ FAILURE: "failure",
229
+ ERROR: "failure",
230
+ NEUTRAL: "neutral",
231
+ SKIPPED: "skipped",
232
+ CANCELLED: "cancelled",
233
+ TIMED_OUT: "timed_out",
178
234
  }
179
235
 
180
- const getContextStatus = (context: GraphQLCheckContext): CheckItem["status"] => {
236
+ const normalizeCheckStatus = (raw: string | null | undefined): CheckItem["status"] =>
237
+ raw ? CHECK_STATUS_BY_RAW[raw] ?? "pending" : "pending"
238
+
239
+ const normalizeCheckConclusion = (raw: string | null | undefined): CheckItem["conclusion"] =>
240
+ raw ? CHECK_CONCLUSION_BY_RAW[raw] ?? null : null
241
+
242
+ const getContextStatus = (context: RawCheckContext): CheckItem["status"] => {
181
243
  if (context.__typename === "CheckRun") return normalizeCheckStatus(context.status)
182
244
  if (context.state === "PENDING") return "in_progress"
183
245
  return "completed"
184
246
  }
185
247
 
186
- const getContextConclusion = (context: GraphQLCheckContext): CheckItem["conclusion"] => {
248
+ const getContextConclusion = (context: RawCheckContext): CheckItem["conclusion"] => {
187
249
  if (context.__typename === "CheckRun") return normalizeCheckConclusion(context.conclusion)
188
250
  if (context.state === "SUCCESS") return "success"
189
251
  if (context.state === "FAILURE" || context.state === "ERROR") return "failure"
190
252
  return null
191
253
  }
192
254
 
193
- const getCheckInfoFromContexts = (contexts: readonly GraphQLCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
255
+ const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
194
256
  if (contexts.length === 0) {
195
257
  return { checkStatus: "none", checkSummary: null, checks: [] }
196
258
  }
@@ -232,39 +294,10 @@ const getCheckInfoFromContexts = (contexts: readonly GraphQLCheckContext[]): Pic
232
294
  return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
233
295
  }
234
296
 
235
- const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> =>
236
- getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
237
-
238
- const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
239
- const checkInfo = getCheckInfo(item)
240
-
241
- return {
242
- repository: item.repository.nameWithOwner,
243
- number: item.number,
244
- title: item.title,
245
- body: item.body,
246
- labels: item.labels.nodes.map((label) => ({
247
- name: label.name,
248
- color: label.color ? `#${label.color}` : null,
249
- })),
250
- additions: item.additions,
251
- deletions: item.deletions,
252
- changedFiles: item.changedFiles,
253
- state: item.state.toLowerCase() === "open" ? "open" : "closed",
254
- reviewStatus: getReviewStatus(item),
255
- checkStatus: checkInfo.checkStatus,
256
- checkSummary: checkInfo.checkSummary,
257
- checks: checkInfo.checks,
258
- autoMergeEnabled: item.autoMergeRequest !== null,
259
- detailLoaded: true,
260
- createdAt: new Date(item.createdAt),
261
- closedAt: normalizeDate(item.closedAt),
262
- url: item.url,
263
- }
264
- }
265
-
266
- const parsePullRequestSummary = (item: GitHubPullRequestSummaryNode): PullRequestItem => ({
297
+ const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestItem => ({
267
298
  repository: item.repository.nameWithOwner,
299
+ author: item.author.login,
300
+ headRefOid: item.headRefOid,
268
301
  number: item.number,
269
302
  title: item.title,
270
303
  body: "",
@@ -272,7 +305,7 @@ const parsePullRequestSummary = (item: GitHubPullRequestSummaryNode): PullReques
272
305
  additions: 0,
273
306
  deletions: 0,
274
307
  changedFiles: 0,
275
- state: item.state.toLowerCase() === "open" ? "open" : "closed",
308
+ state: getPullRequestState(item),
276
309
  reviewStatus: getReviewStatus(item),
277
310
  checkStatus: "none",
278
311
  checkSummary: null,
@@ -284,23 +317,86 @@ const parsePullRequestSummary = (item: GitHubPullRequestSummaryNode): PullReques
284
317
  url: item.url,
285
318
  })
286
319
 
287
- const searchQuery = (author: string) => `author:${author} is:pr is:open sort:created-desc`
320
+ const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
321
+ const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
322
+ return {
323
+ ...parsePullRequestSummary(item),
324
+ body: item.body,
325
+ labels: item.labels.nodes.map((label) => ({
326
+ name: label.name,
327
+ color: label.color ? `#${label.color}` : null,
328
+ })),
329
+ additions: item.additions,
330
+ deletions: item.deletions,
331
+ changedFiles: item.changedFiles,
332
+ checkStatus: checkInfo.checkStatus,
333
+ checkSummary: checkInfo.checkSummary,
334
+ checks: checkInfo.checks,
335
+ detailLoaded: true,
336
+ }
337
+ }
338
+
339
+ const searchQuery = (mode: PullRequestQueueMode, author: string) => `${pullRequestQueueSearchQualifier(mode, author)} is:pr is:open sort:created-desc`
288
340
 
289
- type GitHubError = CommandError | JsonParseError
341
+ const sortNewestFirst = (pullRequests: readonly PullRequestItem[]) =>
342
+ [...pullRequests].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
290
343
 
291
- const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] => {
292
- if (value === "MERGEABLE") return "mergeable"
293
- if (value === "CONFLICTING") return "conflicting"
294
- return "unknown"
344
+ const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
345
+ const line = comment.line ?? comment.original_line
346
+ if (!comment.path || !line || (comment.side !== "LEFT" && comment.side !== "RIGHT")) return null
347
+ return {
348
+ id: String(comment.id ?? comment.node_id ?? `${comment.path}:${comment.side}:${line}:${comment.created_at ?? ""}:${comment.body ?? ""}`),
349
+ path: comment.path,
350
+ line,
351
+ side: comment.side,
352
+ author: comment.user?.login ?? "unknown",
353
+ body: comment.body ?? "",
354
+ createdAt: comment.created_at ? new Date(comment.created_at) : null,
355
+ url: comment.html_url ?? comment.url ?? null,
356
+ }
357
+ }
358
+
359
+ 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) => {
364
+ const parsed = parsePullRequestComment(comment)
365
+ return parsed ? [parsed] : []
366
+ }))
295
367
  }
296
368
 
369
+ const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullRequestReviewComment => ({
370
+ id: `created:${input.repository}:${input.number}:${input.path}:${input.side}:${input.line}:${Date.now()}`,
371
+ path: input.path,
372
+ line: input.line,
373
+ side: input.side,
374
+ author: config.author.replace(/^@/, "") || "you",
375
+ body: input.body,
376
+ createdAt: new Date(),
377
+ url: null,
378
+ })
379
+
380
+ export type GitHubError = CommandError | JsonParseError | Schema.SchemaError
381
+
382
+ const MERGEABLE_BY_RAW: Record<string, PullRequestMergeInfo["mergeable"]> = {
383
+ MERGEABLE: "mergeable",
384
+ CONFLICTING: "conflicting",
385
+ }
386
+
387
+ const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] =>
388
+ MERGEABLE_BY_RAW[value] ?? "unknown"
389
+
297
390
  export class GitHubService extends Context.Service<GitHubService, {
298
- readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
299
- readonly listOpenPullRequestDetails: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
391
+ readonly listOpenPullRequests: (mode: PullRequestQueueMode) => Effect.Effect<readonly PullRequestItem[], GitHubError>
392
+ readonly listOpenPullRequestDetails: (mode: PullRequestQueueMode) => Effect.Effect<readonly PullRequestItem[], GitHubError>
300
393
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
301
394
  readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
395
+ readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
302
396
  readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
303
397
  readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
398
+ readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
399
+ readonly createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.Effect<PullRequestReviewComment, GitHubError>
304
400
  readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
305
401
  readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
306
402
  readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
@@ -311,60 +407,40 @@ export class GitHubService extends Context.Service<GitHubService, {
311
407
  Effect.gen(function*() {
312
408
  const command = yield* CommandRunner
313
409
 
314
- const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*() {
315
- const pullRequests: PullRequestItem[] = []
316
- let cursor: string | null = null
317
-
318
- while (pullRequests.length < config.prFetchLimit) {
319
- const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
320
- const response: GraphQLSearchSummaryResponse = yield* command.runJson<GraphQLSearchSummaryResponse>("gh", [
321
- "api", "graphql",
322
- "-f", `query=${pullRequestSummarySearchQuery}`,
323
- "-F", `searchQuery=${searchQuery(config.author)}`,
324
- "-F", `first=${pageSize}`,
325
- ...(cursor ? ["-F", `after=${cursor}`] : []),
326
- ])
327
-
328
- for (const node of response.data.search.nodes) {
329
- if (node) pullRequests.push(parsePullRequestSummary(node))
410
+ const paginateSearch = <Item extends Schema.Top>(label: string, query: string, schema: Item, parse: (node: Item["Type"]) => PullRequestItem) => {
411
+ 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
330
433
  }
331
434
 
332
- if (!response.data.search.pageInfo.hasNextPage) break
333
- cursor = response.data.search.pageInfo.endCursor
334
- if (!cursor) break
335
- }
336
-
337
- return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
338
- })
339
-
340
- const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function*() {
341
- const pullRequests: PullRequestItem[] = []
342
- let cursor: string | null = null
343
-
344
- while (pullRequests.length < config.prFetchLimit) {
345
- const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
346
- const response: GraphQLSearchResponse = yield* command.runJson<GraphQLSearchResponse>("gh", [
347
- "api", "graphql",
348
- "-f", `query=${pullRequestSearchQuery}`,
349
- "-F", `searchQuery=${searchQuery(config.author)}`,
350
- "-F", `first=${pageSize}`,
351
- ...(cursor ? ["-F", `after=${cursor}`] : []),
352
- ])
353
-
354
- for (const node of response.data.search.nodes) {
355
- if (node) pullRequests.push(parsePullRequest(node))
356
- }
357
-
358
- if (!response.data.search.pageInfo.hasNextPage) break
359
- cursor = response.data.search.pageInfo.endCursor
360
- if (!cursor) break
361
- }
435
+ return sortNewestFirst(pullRequests)
436
+ })
437
+ }
362
438
 
363
- return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
364
- })
439
+ const listOpenPullRequests = paginateSearch("listOpenPullRequests", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
440
+ const listOpenPullRequestDetails = paginateSearch("listOpenPullRequestDetails", pullRequestSearchQuery, RawPullRequestNodeSchema, parsePullRequest)
365
441
 
366
442
  const getAuthenticatedUser = Effect.fn("GitHubService.getAuthenticatedUser")(function*() {
367
- const viewer = yield* command.runJson<GitHubViewer>("gh", ["api", "user"])
443
+ const viewer = yield* command.runSchema(ViewerSchema, "gh", ["api", "user"])
368
444
  return viewer.login
369
445
  })
370
446
 
@@ -373,8 +449,15 @@ export class GitHubService extends Context.Service<GitHubService, {
373
449
  return result.stdout
374
450
  })
375
451
 
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`,
455
+ ])
456
+ return parsePullRequestComments(response)
457
+ })
458
+
376
459
  const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
377
- const info = yield* command.runJson<GitHubMergeInfoResponse>("gh", [
460
+ const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
378
461
  "pr", "view", String(number), "--repo", repository,
379
462
  "--json", "number,title,state,isDraft,mergeable,reviewDecision,autoMergeRequest,statusCheckRollup",
380
463
  ])
@@ -399,12 +482,28 @@ export class GitHubService extends Context.Service<GitHubService, {
399
482
  yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
400
483
  })
401
484
 
485
+ const closePullRequest = Effect.fn("GitHubService.closePullRequest")(function*(repository: string, number: number) {
486
+ yield* command.run("gh", ["pr", "close", String(number), "--repo", repository])
487
+ })
488
+
489
+ const createPullRequestComment = Effect.fn("GitHubService.createPullRequestComment")(function*(input: CreatePullRequestCommentInput) {
490
+ const response = yield* command.runSchema(PullRequestCommentSchema, "gh", [
491
+ "api", "--method", "POST", `repos/${input.repository}/pulls/${input.number}/comments`,
492
+ "-f", `body=${input.body}`,
493
+ "-f", `commit_id=${input.commitId}`,
494
+ "-f", `path=${input.path}`,
495
+ "-F", `line=${input.line}`,
496
+ "-f", `side=${input.side}`,
497
+ ])
498
+ return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
499
+ })
500
+
402
501
  const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
403
502
  yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
404
503
  })
405
504
 
406
505
  const listRepoLabels = Effect.fn("GitHubService.listRepoLabels")(function*(repository: string) {
407
- const labels = yield* command.runJson<readonly { name: string; color: string }[]>("gh", [
506
+ const labels = yield* command.runSchema(RepoLabelsResponseSchema, "gh", [
408
507
  "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
409
508
  ])
410
509
  return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
@@ -423,8 +522,11 @@ export class GitHubService extends Context.Service<GitHubService, {
423
522
  listOpenPullRequestDetails,
424
523
  getAuthenticatedUser,
425
524
  getPullRequestDiff,
525
+ listPullRequestComments,
426
526
  getPullRequestMergeInfo,
427
527
  mergePullRequest,
528
+ closePullRequest,
529
+ createPullRequestComment,
428
530
  toggleDraftStatus,
429
531
  listRepoLabels,
430
532
  addPullRequestLabel,