@kitlangton/ghui 0.1.17 → 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
+ })
32
+
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>
64
123
 
65
- interface GraphQLSearchSummaryResponse {
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,24 +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
+ }
288
338
 
289
- type GitHubError = CommandError | JsonParseError
339
+ const searchQuery = (mode: PullRequestQueueMode, author: string) => `${pullRequestQueueSearchQualifier(mode, author)} is:pr is:open sort:created-desc`
290
340
 
291
- const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] => {
292
- if (value === "MERGEABLE") return "mergeable"
293
- if (value === "CONFLICTING") return "conflicting"
294
- return "unknown"
341
+ const sortNewestFirst = (pullRequests: readonly PullRequestItem[]) =>
342
+ [...pullRequests].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
343
+
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
+ }))
367
+ }
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",
295
385
  }
296
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>
304
398
  readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
399
+ readonly createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.Effect<PullRequestReviewComment, GitHubError>
305
400
  readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
306
401
  readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
307
402
  readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
@@ -312,60 +407,40 @@ export class GitHubService extends Context.Service<GitHubService, {
312
407
  Effect.gen(function*() {
313
408
  const command = yield* CommandRunner
314
409
 
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))
331
- }
332
-
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))
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
357
433
  }
358
434
 
359
- if (!response.data.search.pageInfo.hasNextPage) break
360
- cursor = response.data.search.pageInfo.endCursor
361
- if (!cursor) break
362
- }
435
+ return sortNewestFirst(pullRequests)
436
+ })
437
+ }
363
438
 
364
- return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
365
- })
439
+ const listOpenPullRequests = paginateSearch("listOpenPullRequests", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
440
+ const listOpenPullRequestDetails = paginateSearch("listOpenPullRequestDetails", pullRequestSearchQuery, RawPullRequestNodeSchema, parsePullRequest)
366
441
 
367
442
  const getAuthenticatedUser = Effect.fn("GitHubService.getAuthenticatedUser")(function*() {
368
- const viewer = yield* command.runJson<GitHubViewer>("gh", ["api", "user"])
443
+ const viewer = yield* command.runSchema(ViewerSchema, "gh", ["api", "user"])
369
444
  return viewer.login
370
445
  })
371
446
 
@@ -374,8 +449,15 @@ export class GitHubService extends Context.Service<GitHubService, {
374
449
  return result.stdout
375
450
  })
376
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
+
377
459
  const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
378
- const info = yield* command.runJson<GitHubMergeInfoResponse>("gh", [
460
+ const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
379
461
  "pr", "view", String(number), "--repo", repository,
380
462
  "--json", "number,title,state,isDraft,mergeable,reviewDecision,autoMergeRequest,statusCheckRollup",
381
463
  ])
@@ -404,12 +486,24 @@ export class GitHubService extends Context.Service<GitHubService, {
404
486
  yield* command.run("gh", ["pr", "close", String(number), "--repo", repository])
405
487
  })
406
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
+
407
501
  const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
408
502
  yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
409
503
  })
410
504
 
411
505
  const listRepoLabels = Effect.fn("GitHubService.listRepoLabels")(function*(repository: string) {
412
- const labels = yield* command.runJson<readonly { name: string; color: string }[]>("gh", [
506
+ const labels = yield* command.runSchema(RepoLabelsResponseSchema, "gh", [
413
507
  "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
414
508
  ])
415
509
  return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
@@ -428,9 +522,11 @@ export class GitHubService extends Context.Service<GitHubService, {
428
522
  listOpenPullRequestDetails,
429
523
  getAuthenticatedUser,
430
524
  getPullRequestDiff,
525
+ listPullRequestComments,
431
526
  getPullRequestMergeInfo,
432
527
  mergePullRequest,
433
528
  closePullRequest,
529
+ createPullRequestComment,
434
530
  toggleDraftStatus,
435
531
  listRepoLabels,
436
532
  addPullRequestLabel,