@kitlangton/ghui 0.3.0 → 0.3.3

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.
Files changed (55) hide show
  1. package/README.md +13 -3
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9456 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2779
  6. package/src/appCommands.ts +0 -409
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -20
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -149
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -116
  15. package/src/keymap/changedFilesModal.ts +0 -23
  16. package/src/keymap/closeModal.ts +0 -13
  17. package/src/keymap/commandPalette.ts +0 -16
  18. package/src/keymap/commentModal.ts +0 -73
  19. package/src/keymap/commentThreadModal.ts +0 -24
  20. package/src/keymap/detailView.ts +0 -30
  21. package/src/keymap/diffView.ts +0 -93
  22. package/src/keymap/filterMode.ts +0 -13
  23. package/src/keymap/helpers.ts +0 -24
  24. package/src/keymap/labelModal.ts +0 -16
  25. package/src/keymap/listNav.ts +0 -119
  26. package/src/keymap/mergeModal.ts +0 -23
  27. package/src/keymap/openRepositoryModal.ts +0 -13
  28. package/src/keymap/submitReviewModal.ts +0 -48
  29. package/src/keymap/themeModal.ts +0 -49
  30. package/src/mergeActions.ts +0 -88
  31. package/src/observability.ts +0 -46
  32. package/src/pullRequestCache.ts +0 -19
  33. package/src/pullRequestViews.ts +0 -43
  34. package/src/services/BrowserOpener.ts +0 -22
  35. package/src/services/Clipboard.ts +0 -46
  36. package/src/services/CommandRunner.ts +0 -91
  37. package/src/services/GitHubService.ts +0 -756
  38. package/src/services/MockGitHubService.ts +0 -182
  39. package/src/themeStore.ts +0 -60
  40. package/src/ui/CommandPalette.tsx +0 -176
  41. package/src/ui/DetailsPane.tsx +0 -656
  42. package/src/ui/FooterHints.tsx +0 -90
  43. package/src/ui/LoadingLogo.tsx +0 -75
  44. package/src/ui/PullRequestDiffPane.tsx +0 -249
  45. package/src/ui/PullRequestList.tsx +0 -194
  46. package/src/ui/colors.ts +0 -821
  47. package/src/ui/commentEditor.ts +0 -126
  48. package/src/ui/comments.tsx +0 -143
  49. package/src/ui/diff.ts +0 -650
  50. package/src/ui/diffStats.tsx +0 -25
  51. package/src/ui/modals.tsx +0 -964
  52. package/src/ui/primitives.tsx +0 -350
  53. package/src/ui/pullRequests.ts +0 -106
  54. package/src/ui/singleLineInput.ts +0 -26
  55. package/src/ui/spinner.ts +0 -1
@@ -1,756 +0,0 @@
1
- import { Context, Effect, Layer, Schema } from "effect"
2
- import { config } from "../config.js"
3
- import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestConversationItem, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus, type SubmitPullRequestReviewInput } from "../domain.js"
4
- import { getMergeActionDefinition } from "../mergeActions.js"
5
- import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js"
6
-
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
- })
31
-
32
- const RawStatusCheckRollupSchema = Schema.Struct({
33
- contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
34
- })
35
-
36
- const RawPullRequestSummaryFields = {
37
- number: Schema.Number,
38
- title: Schema.String,
39
- isDraft: Schema.Boolean,
40
- reviewDecision: NullableString,
41
- autoMergeRequest: Schema.NullOr(Schema.Unknown),
42
- state: Schema.String,
43
- merged: Schema.Boolean,
44
- createdAt: Schema.String,
45
- closedAt: OptionalNullableString,
46
- url: Schema.String,
47
- author: RawAuthorSchema,
48
- headRefOid: Schema.String,
49
- repository: RawRepositorySchema,
50
- } as const
51
-
52
- const RawPullRequestSummaryNodeSchema = Schema.Struct({
53
- ...RawPullRequestSummaryFields,
54
- statusCheckRollup: Schema.optionalKey(Schema.NullOr(RawStatusCheckRollupSchema)),
55
- })
56
-
57
- const RawPullRequestNodeSchema = Schema.Struct({
58
- ...RawPullRequestSummaryFields,
59
- body: Schema.String,
60
- labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }),
61
- additions: Schema.Number,
62
- deletions: Schema.Number,
63
- changedFiles: Schema.Number,
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
- }),
73
- })
74
-
75
- const PageInfoSchema = Schema.Struct({
76
- hasNextPage: Schema.Boolean,
77
- endCursor: NullableString,
78
- })
79
-
80
- const SearchResponseSchema = <Item extends Schema.Top>(item: Item) =>
81
- Schema.Struct({
82
- data: Schema.Struct({
83
- search: Schema.Struct({
84
- nodes: Schema.Array(Schema.NullOr(item)),
85
- pageInfo: PageInfoSchema,
86
- }),
87
- }),
88
- })
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
-
101
- const ViewerSchema = Schema.Struct({ login: Schema.String })
102
-
103
- const MergeInfoResponseSchema = Schema.Struct({
104
- number: Schema.Number,
105
- title: Schema.String,
106
- state: Schema.String,
107
- isDraft: Schema.Boolean,
108
- mergeable: Schema.String,
109
- reviewDecision: NullableString,
110
- autoMergeRequest: Schema.NullOr(Schema.Unknown),
111
- statusCheckRollup: Schema.Array(RawCheckContextSchema),
112
- })
113
-
114
- const PullRequestCommentSchema = Schema.Struct({
115
- id: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
116
- node_id: OptionalNullableString,
117
- body: OptionalNullableString,
118
- html_url: OptionalNullableString,
119
- url: OptionalNullableString,
120
- created_at: OptionalNullableString,
121
- user: Schema.optionalKey(Schema.NullOr(Schema.Struct({
122
- login: OptionalNullableString,
123
- }))),
124
- path: OptionalNullableString,
125
- line: OptionalNullableNumber,
126
- original_line: OptionalNullableNumber,
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,
135
- })
136
-
137
- const CommentsResponseSchema = Schema.Union([
138
- Schema.Array(PullRequestCommentSchema),
139
- Schema.Array(Schema.Array(PullRequestCommentSchema)),
140
- ])
141
-
142
- const PullRequestFilesResponseSchema = Schema.Union([
143
- Schema.Array(PullRequestFileSchema),
144
- Schema.Array(Schema.Array(PullRequestFileSchema)),
145
- ])
146
-
147
- const RepoLabelsResponseSchema = Schema.Array(Schema.Struct({
148
- name: Schema.String,
149
- color: Schema.String,
150
- }))
151
-
152
- type RawPullRequestSummaryNode = Schema.Schema.Type<typeof RawPullRequestSummaryNodeSchema>
153
- type RawPullRequestNode = Schema.Schema.Type<typeof RawPullRequestNodeSchema>
154
- type RawCheckContext = Schema.Schema.Type<typeof RawCheckContextSchema>
155
- type RawPullRequestComment = Schema.Schema.Type<typeof PullRequestCommentSchema>
156
- type RawPullRequestFile = Schema.Schema.Type<typeof PullRequestFileSchema>
157
-
158
- type SearchResponse<Item> = {
159
- readonly data: {
160
- readonly search: {
161
- readonly nodes: readonly (Item | null)[]
162
- readonly pageInfo: {
163
- readonly hasNextPage: boolean
164
- readonly endCursor: string | null
165
- }
166
- }
167
- }
168
- }
169
-
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 = `
205
- number
206
- title
207
- body
208
- isDraft
209
- reviewDecision
210
- autoMergeRequest { enabledAt }
211
- additions
212
- deletions
213
- changedFiles
214
- state
215
- merged
216
- createdAt
217
- closedAt
218
- url
219
- author { login }
220
- headRefOid
221
- repository { nameWithOwner }
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}
229
- }
230
- }
231
- pageInfo { hasNextPage endCursor }
232
- }
233
- }
234
- `
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
-
245
- const pullRequestSummarySearchQuery = `
246
- query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
247
- search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
248
- nodes {
249
- ... on PullRequest {${SUMMARY_FIELDS_FRAGMENT}
250
- }
251
- }
252
- pageInfo { hasNextPage endCursor }
253
- }
254
- }
255
- `
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
-
269
- const normalizeDate = (value: string | null | undefined) => {
270
- if (!value || value.startsWith("0001-01-01")) return null
271
- return new Date(value)
272
- }
273
-
274
- const getPullRequestState = (item: { readonly state: string; readonly merged: boolean }): PullRequestItem["state"] =>
275
- item.merged ? "merged" : item.state.toLowerCase() === "open" ? "open" : "closed"
276
-
277
- const REVIEW_STATUS_BY_DECISION: Record<string, ReviewStatus> = {
278
- APPROVED: "approved",
279
- CHANGES_REQUESTED: "changes",
280
- REVIEW_REQUIRED: "review",
281
- }
282
-
283
- const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): ReviewStatus => {
284
- if (item.isDraft) return "draft"
285
- if (item.reviewDecision) return REVIEW_STATUS_BY_DECISION[item.reviewDecision] ?? "none"
286
- return "none"
287
- }
288
-
289
- const CHECK_STATUS_BY_RAW: Record<string, CheckItem["status"]> = {
290
- COMPLETED: "completed",
291
- IN_PROGRESS: "in_progress",
292
- QUEUED: "queued",
293
- }
294
-
295
- const CHECK_CONCLUSION_BY_RAW: Record<string, NonNullable<CheckItem["conclusion"]>> = {
296
- SUCCESS: "success",
297
- FAILURE: "failure",
298
- ERROR: "failure",
299
- NEUTRAL: "neutral",
300
- SKIPPED: "skipped",
301
- CANCELLED: "cancelled",
302
- TIMED_OUT: "timed_out",
303
- }
304
-
305
- const normalizeCheckStatus = (raw: string | null | undefined): CheckItem["status"] =>
306
- raw ? CHECK_STATUS_BY_RAW[raw] ?? "pending" : "pending"
307
-
308
- const normalizeCheckConclusion = (raw: string | null | undefined): CheckItem["conclusion"] =>
309
- raw ? CHECK_CONCLUSION_BY_RAW[raw] ?? null : null
310
-
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
- })
316
-
317
- const STATUS_CONTEXT_CONCLUSION: Record<string, NonNullable<CheckItem["conclusion"]>> = {
318
- SUCCESS: "success",
319
- FAILURE: "failure",
320
- ERROR: "failure",
321
- }
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
-
329
- const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
330
- if (contexts.length === 0) {
331
- return { checkStatus: "none", checkSummary: null, checks: [] }
332
- }
333
-
334
- let completed = 0
335
- let successful = 0
336
- let pending = false
337
- let failing = false
338
- const checks: CheckItem[] = []
339
-
340
- for (const check of contexts) {
341
- const name = check.__typename === "CheckRun" ? check.name ?? "check" : check.context ?? "check"
342
- const status = getContextStatus(check)
343
- const conclusion = getContextConclusion(check)
344
-
345
- checks.push({ name, status, conclusion })
346
-
347
- if (status === "completed") {
348
- completed += 1
349
- } else {
350
- pending = true
351
- }
352
-
353
- if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
354
- successful += 1
355
- } else if (conclusion) {
356
- failing = true
357
- }
358
- }
359
-
360
- if (pending) {
361
- return { checkStatus: "pending", checkSummary: `checks ${completed}/${contexts.length}`, checks }
362
- }
363
-
364
- if (failing) {
365
- return { checkStatus: "failing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
366
- }
367
-
368
- return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
369
- }
370
-
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
- }
396
-
397
- const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
398
- const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
399
- return {
400
- ...parsePullRequestSummary(item),
401
- body: item.body,
402
- labels: item.labels.nodes.map((label) => ({
403
- name: label.name,
404
- color: label.color ? `#${label.color}` : null,
405
- })),
406
- additions: item.additions,
407
- deletions: item.deletions,
408
- changedFiles: item.changedFiles,
409
- checkStatus: checkInfo.checkStatus,
410
- checkSummary: checkInfo.checkSummary,
411
- checks: checkInfo.checks,
412
- detailLoaded: true,
413
- }
414
- }
415
-
416
- const searchQuery = (mode: PullRequestQueueMode, repository: string | null) => {
417
- const sort = mode === "repository" ? "sort:updated-desc" : "sort:created-desc"
418
- return `${pullRequestQueueSearchQualifier(mode, 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
- })
426
-
427
- const repositoryParts = (repository: string) => {
428
- const [owner, name] = repository.split("/")
429
- return owner && name ? { owner, name } : null
430
- }
431
-
432
- const rawCommentFields = (comment: RawPullRequestComment, fallbackId: string) => ({
433
- id: String(comment.id ?? comment.node_id ?? fallbackId),
434
- author: comment.user?.login ?? "unknown",
435
- body: comment.body ?? "",
436
- createdAt: comment.created_at ? new Date(comment.created_at) : null,
437
- url: comment.html_url ?? comment.url ?? null,
438
- })
439
-
440
- const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
441
- const line = comment.line ?? comment.original_line
442
- if (!comment.path || !line || (comment.side !== "LEFT" && comment.side !== "RIGHT")) return null
443
- return {
444
- ...rawCommentFields(comment, `${comment.path}:${comment.side}:${line}:${comment.created_at ?? ""}:${comment.body ?? ""}`),
445
- path: comment.path,
446
- line,
447
- side: comment.side,
448
- }
449
- }
450
-
451
- const parsePullRequestComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestReviewComment[] => {
452
- return flattenSlurpedPages(response).flatMap((comment) => {
453
- const parsed = parsePullRequestComment(comment)
454
- return parsed ? [parsed] : []
455
- })
456
- }
457
-
458
- const parseIssueComment = (comment: RawPullRequestComment): PullRequestConversationItem => ({
459
- _tag: "comment",
460
- ...rawCommentFields(comment, `${comment.created_at ?? ""}:${comment.body ?? ""}`),
461
- })
462
-
463
- const reviewCommentConversationItem = (comment: PullRequestReviewComment): PullRequestConversationItem => ({
464
- _tag: "review-comment",
465
- ...comment,
466
- })
467
-
468
- const conversationItemTime = (item: PullRequestConversationItem) => item.createdAt?.getTime() ?? Number.MAX_SAFE_INTEGER
469
-
470
- const sortConversationItems = (items: readonly PullRequestConversationItem[]) =>
471
- [...items].sort((left, right) => conversationItemTime(left) - conversationItemTime(right) || left.id.localeCompare(right.id))
472
-
473
- const parseIssueComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestConversationItem[] =>
474
- flattenSlurpedPages(response).map(parseIssueComment)
475
-
476
- const flattenSlurpedPages = <Item>(response: readonly Item[] | readonly (readonly Item[])[]): readonly Item[] =>
477
- Array.isArray(response[0]) ? (response as readonly (readonly Item[])[]).flat() : response as readonly Item[]
478
-
479
- const parsePullRequestFiles = (response: Schema.Schema.Type<typeof PullRequestFilesResponseSchema>): readonly RawPullRequestFile[] =>
480
- flattenSlurpedPages(response)
481
-
482
- const diffPath = (path: string) => /\s|"/.test(path) ? JSON.stringify(path) : path
483
-
484
- const prefixedDiffPath = (prefix: "a" | "b", path: string) => diffPath(`${prefix}/${path}`)
485
-
486
- const fileHeaderPatch = (file: RawPullRequestFile) => {
487
- const oldPath = file.previous_filename ?? file.filename
488
- const newPath = file.filename
489
- const oldRef = file.status === "added" ? "/dev/null" : prefixedDiffPath("a", oldPath)
490
- const newRef = file.status === "removed" ? "/dev/null" : prefixedDiffPath("b", newPath)
491
- const lines = [
492
- `diff --git ${prefixedDiffPath("a", oldPath)} ${prefixedDiffPath("b", newPath)}`,
493
- ...(file.status === "renamed" && file.previous_filename ? [`rename from ${oldPath}`, `rename to ${newPath}`] : []),
494
- `--- ${oldRef}`,
495
- `+++ ${newRef}`,
496
- ]
497
- if (file.patch) lines.push(file.patch.trimEnd())
498
- return lines.join("\n")
499
- }
500
-
501
- export const pullRequestFilesToPatch = (files: readonly RawPullRequestFile[]) =>
502
- files.map(fileHeaderPatch).join("\n")
503
-
504
- const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullRequestReviewComment => ({
505
- id: `created:${input.repository}:${input.number}:${input.path}:${input.side}:${input.line}:${Date.now()}`,
506
- path: input.path,
507
- line: input.line,
508
- side: input.side,
509
- author: "you",
510
- body: input.body,
511
- createdAt: new Date(),
512
- url: null,
513
- })
514
-
515
- export type GitHubError = CommandError | JsonParseError | Schema.SchemaError
516
-
517
- const MERGEABLE_BY_RAW: Record<string, Mergeable> = {
518
- MERGEABLE: "mergeable",
519
- CONFLICTING: "conflicting",
520
- }
521
-
522
- const normalizeMergeable = (value: string): Mergeable =>
523
- MERGEABLE_BY_RAW[value] ?? "unknown"
524
-
525
- const REVIEW_EVENT_CLI_FLAG = {
526
- COMMENT: "--comment",
527
- APPROVE: "--approve",
528
- REQUEST_CHANGES: "--request-changes",
529
- } as const satisfies Record<SubmitPullRequestReviewInput["event"], string>
530
-
531
- export class GitHubService extends Context.Service<GitHubService, {
532
- readonly listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
533
- readonly listOpenPullRequestPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>
534
- readonly listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
535
- readonly getPullRequestDetails: (repository: string, number: number) => Effect.Effect<PullRequestItem, GitHubError>
536
- readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
537
- readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, GitHubError>
538
- readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
539
- readonly listPullRequestConversation: (repository: string, number: number) => Effect.Effect<readonly PullRequestConversationItem[], GitHubError>
540
- readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
541
- readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
542
- readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
543
- readonly createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.Effect<PullRequestReviewComment, GitHubError>
544
- readonly submitPullRequestReview: (input: SubmitPullRequestReviewInput) => Effect.Effect<void, CommandError>
545
- readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
546
- readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
547
- readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
548
- readonly removePullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
549
- }>()("ghui/GitHubService") {
550
- static readonly layerNoDeps = Layer.effect(
551
- GitHubService,
552
- Effect.gen(function*() {
553
- const command = yield* CommandRunner
554
-
555
- const ghJson = <S extends Schema.Top>(label: string, schema: S, args: readonly string[]) =>
556
- command.runSchema(schema, "gh", args).pipe(Effect.withSpan(`GitHubService.${label}`))
557
-
558
- const ghVoid = (label: string, args: readonly string[]) =>
559
- command.run("gh", args).pipe(Effect.withSpan(`GitHubService.${label}`), Effect.asVoid)
560
-
561
- const searchPage = <Item extends Schema.Top>(label: string, query: string, schema: Item, parse: (node: Item["Type"]) => PullRequestItem) => {
562
- const responseSchema = SearchResponseSchema(schema)
563
- return Effect.fn(`GitHubService.${label}`)(function*(input: ListPullRequestPageInput) {
564
- const response: SearchResponse<Item["Type"]> = yield* command.runSchema(responseSchema, "gh", [
565
- "api", "graphql",
566
- "-f", `query=${query}`,
567
- "-F", `searchQuery=${searchQuery(input.mode, input.repository)}`,
568
- "-F", `first=${input.pageSize}`,
569
- ...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
570
- ])
571
- return pullRequestPage(response.data.search, parse)
572
- })
573
- }
574
-
575
- const listOpenPullRequestSearchPage = searchPage("listOpenPullRequestSearchPage", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
576
- const listOpenPullRequestDetailsPage = searchPage("listOpenPullRequestDetailsPage", pullRequestSearchQuery, RawPullRequestNodeSchema, parsePullRequest)
577
-
578
- const listRepositoryPullRequestPage = Effect.fn("GitHubService.listRepositoryPullRequestPage")(function*(input: ListPullRequestPageInput) {
579
- if (!input.repository) return { items: [], endCursor: null, hasNextPage: false } satisfies PullRequestPage
580
- const repo = repositoryParts(input.repository)
581
- if (!repo) {
582
- return yield* new CommandError({ command: "gh", args: [], detail: `Invalid repository: ${input.repository}`, cause: input.repository })
583
- }
584
-
585
- const response = yield* command.runSchema(RepositoryPullRequestsResponseSchema, "gh", [
586
- "api", "graphql",
587
- "-f", `query=${repositoryPullRequestsQuery}`,
588
- "-F", `owner=${repo.owner}`,
589
- "-F", `name=${repo.name}`,
590
- "-F", `first=${input.pageSize}`,
591
- ...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
592
- ])
593
- const connection = response.data.repository?.pullRequests
594
- if (!connection) {
595
- return yield* new CommandError({ command: "gh", args: [], detail: `Repository not found: ${input.repository}`, cause: input.repository })
596
- }
597
- return pullRequestPage(connection, parsePullRequestSummary)
598
- })
599
-
600
- const listOpenPullRequestPage = Effect.fn("GitHubService.listOpenPullRequestPage")(function*(input: ListPullRequestPageInput) {
601
- const pageSize = Math.max(1, Math.min(100, input.pageSize))
602
- const pageInput = { ...input, pageSize }
603
- if (pageInput.mode === "repository" && pageInput.repository) return yield* listRepositoryPullRequestPage(pageInput)
604
- return yield* listOpenPullRequestSearchPage(pageInput)
605
- })
606
-
607
- const paginatePages = Effect.fn("GitHubService.paginatePages")(function*(mode: PullRequestQueueMode, repository: string | null, loadPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>) {
608
- const pullRequests: PullRequestItem[] = []
609
- let cursor: string | null = null
610
-
611
- while (pullRequests.length < config.prFetchLimit) {
612
- const page: PullRequestPage = yield* loadPage({ mode, repository, cursor, pageSize: Math.min(100, config.prFetchLimit - pullRequests.length) })
613
- pullRequests.push(...page.items)
614
- if (!page.hasNextPage || !page.endCursor) break
615
- cursor = page.endCursor
616
- }
617
-
618
- return pullRequests
619
- })
620
-
621
- const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*(mode: PullRequestQueueMode, repository: string | null) {
622
- return yield* paginatePages(mode, repository, listOpenPullRequestPage)
623
- })
624
- const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function*(mode: PullRequestQueueMode, repository: string | null) {
625
- return yield* paginatePages(mode, repository, listOpenPullRequestDetailsPage)
626
- })
627
-
628
- const getPullRequestDetails = Effect.fn("GitHubService.getPullRequestDetails")(function*(repository: string, number: number) {
629
- const repo = repositoryParts(repository)
630
- if (!repo) {
631
- return yield* new CommandError({ command: "gh", args: [], detail: `Invalid repository: ${repository}`, cause: repository })
632
- }
633
-
634
- const response = yield* command.runSchema(PullRequestDetailResponseSchema, "gh", [
635
- "api", "graphql",
636
- "-f", `query=${pullRequestDetailQuery}`,
637
- "-F", `owner=${repo.owner}`,
638
- "-F", `name=${repo.name}`,
639
- "-F", `number=${number}`,
640
- ])
641
- const pullRequest = response.data.repository?.pullRequest
642
- if (!pullRequest) {
643
- return yield* new CommandError({ command: "gh", args: [], detail: `Pull request not found: ${repository}#${number}`, cause: `${repository}#${number}` })
644
- }
645
- return parsePullRequest(pullRequest)
646
- })
647
-
648
- const getAuthenticatedUser = () =>
649
- ghJson("getAuthenticatedUser", ViewerSchema, ["api", "user"]).pipe(Effect.map((viewer) => viewer.login))
650
-
651
- const getPullRequestDiff = (repository: string, number: number) =>
652
- ghJson("getPullRequestDiff", PullRequestFilesResponseSchema, [
653
- "api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/files`,
654
- ]).pipe(Effect.map((response) => pullRequestFilesToPatch(parsePullRequestFiles(response))))
655
-
656
- const listPullRequestComments = (repository: string, number: number) =>
657
- ghJson("listPullRequestComments", CommentsResponseSchema, [
658
- "api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/comments`,
659
- ]).pipe(Effect.map(parsePullRequestComments))
660
-
661
- const listPullRequestConversation = Effect.fn("GitHubService.listPullRequestConversation")(function*(repository: string, number: number) {
662
- const [issueComments, reviewComments] = yield* Effect.all([
663
- ghJson("listPullRequestIssueComments", CommentsResponseSchema, [
664
- "api", "--paginate", "--slurp", `repos/${repository}/issues/${number}/comments`,
665
- ]).pipe(Effect.map(parseIssueComments)),
666
- listPullRequestComments(repository, number).pipe(Effect.map((comments) => comments.map(reviewCommentConversationItem))),
667
- ], { concurrency: "unbounded" })
668
-
669
- return sortConversationItems([...issueComments, ...reviewComments])
670
- })
671
-
672
- const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
673
- const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
674
- "pr", "view", String(number), "--repo", repository,
675
- "--json", "number,title,state,isDraft,mergeable,reviewDecision,autoMergeRequest,statusCheckRollup",
676
- ])
677
- const checkInfo = getCheckInfoFromContexts(info.statusCheckRollup)
678
-
679
- return {
680
- repository,
681
- number: info.number,
682
- title: info.title,
683
- state: info.state.toLowerCase() === "open" ? "open" : "closed",
684
- isDraft: info.isDraft,
685
- mergeable: normalizeMergeable(info.mergeable),
686
- reviewStatus: getReviewStatus(info),
687
- checkStatus: checkInfo.checkStatus,
688
- checkSummary: checkInfo.checkSummary,
689
- autoMergeEnabled: info.autoMergeRequest !== null,
690
- } satisfies PullRequestMergeInfo
691
- })
692
-
693
- const mergePullRequest = (repository: string, number: number, action: PullRequestMergeAction) =>
694
- ghVoid("mergePullRequest", ["pr", "merge", String(number), "--repo", repository, ...getMergeActionDefinition(action).cliArgs])
695
-
696
- const closePullRequest = (repository: string, number: number) =>
697
- ghVoid("closePullRequest", ["pr", "close", String(number), "--repo", repository])
698
-
699
- const createPullRequestComment = Effect.fn("GitHubService.createPullRequestComment")(function*(input: CreatePullRequestCommentInput) {
700
- const response = yield* command.runSchema(PullRequestCommentSchema, "gh", [
701
- "api", "--method", "POST", `repos/${input.repository}/pulls/${input.number}/comments`,
702
- "-f", `body=${input.body}`,
703
- "-f", `commit_id=${input.commitId}`,
704
- "-f", `path=${input.path}`,
705
- "-F", `line=${input.line}`,
706
- "-f", `side=${input.side}`,
707
- ...(input.startLine === undefined ? [] : ["-F", `start_line=${input.startLine}`, "-f", `start_side=${input.startSide ?? input.side}`]),
708
- ])
709
- return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
710
- })
711
-
712
- const submitPullRequestReview = (input: SubmitPullRequestReviewInput) =>
713
- ghVoid("submitPullRequestReview", [
714
- "pr", "review", String(input.number), "--repo", input.repository,
715
- REVIEW_EVENT_CLI_FLAG[input.event],
716
- "--body", input.body,
717
- ])
718
-
719
- const toggleDraftStatus = (repository: string, number: number, isDraft: boolean) =>
720
- ghVoid("toggleDraftStatus", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
721
-
722
- const listRepoLabels = (repository: string) =>
723
- ghJson("listRepoLabels", RepoLabelsResponseSchema, [
724
- "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
725
- ]).pipe(Effect.map((labels) => labels.map((label) => ({ name: label.name, color: `#${label.color}` }))))
726
-
727
- const addPullRequestLabel = (repository: string, number: number, label: string) =>
728
- ghVoid("addPullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
729
-
730
- const removePullRequestLabel = (repository: string, number: number, label: string) =>
731
- ghVoid("removePullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
732
-
733
- return GitHubService.of({
734
- listOpenPullRequests,
735
- listOpenPullRequestPage,
736
- listOpenPullRequestDetails,
737
- getPullRequestDetails,
738
- getAuthenticatedUser,
739
- getPullRequestDiff,
740
- listPullRequestComments,
741
- listPullRequestConversation,
742
- getPullRequestMergeInfo,
743
- mergePullRequest,
744
- closePullRequest,
745
- createPullRequestComment,
746
- submitPullRequestReview,
747
- toggleDraftStatus,
748
- listRepoLabels,
749
- addPullRequestLabel,
750
- removePullRequestLabel,
751
- })
752
- }),
753
- )
754
-
755
- static readonly layer = GitHubService.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
756
- }