@kitlangton/ghui 0.1.17 → 0.1.19

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