@kitlangton/ghui 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/bin/ghui.js +64 -1
- package/package.json +6 -2
- package/src/App.tsx +932 -510
- package/src/appCommands.ts +330 -0
- package/src/commands.ts +68 -0
- package/src/config.ts +10 -0
- package/src/domain.ts +43 -13
- package/src/errors.ts +10 -0
- package/src/index.tsx +23 -2
- package/src/mergeActions.ts +1 -6
- package/src/pullRequestCache.ts +19 -0
- package/src/pullRequestViews.ts +45 -0
- package/src/services/BrowserOpener.ts +22 -0
- package/src/services/Clipboard.ts +46 -0
- package/src/services/CommandRunner.ts +14 -6
- package/src/services/GitHubService.ts +327 -161
- package/src/services/MockGitHubService.ts +146 -0
- package/src/ui/CommandPalette.tsx +143 -0
- package/src/ui/DetailsPane.tsx +49 -63
- package/src/ui/FooterHints.tsx +91 -182
- package/src/ui/PullRequestDiffPane.tsx +105 -87
- package/src/ui/PullRequestList.tsx +102 -49
- package/src/ui/colors.ts +167 -1
- package/src/ui/diff.ts +69 -63
- package/src/ui/diffStats.tsx +25 -0
- package/src/ui/modals.tsx +270 -302
- package/src/ui/primitives.tsx +92 -2
- package/src/ui/pullRequests.ts +44 -12
- package/src/ui/singleLineInput.ts +25 -0
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Schema } from "effect"
|
|
2
2
|
import { config } from "../config.js"
|
|
3
|
-
import { pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestQueueMode, type PullRequestReviewComment } from "../domain.js"
|
|
3
|
+
import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
|
|
4
4
|
import { getMergeActionDefinition } from "../mergeActions.js"
|
|
5
|
-
import {
|
|
5
|
+
import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js"
|
|
6
6
|
|
|
7
7
|
const NullableString = Schema.NullOr(Schema.String)
|
|
8
|
-
const OptionalNullableString = Schema.
|
|
9
|
-
const OptionalNullableNumber = Schema.
|
|
8
|
+
const OptionalNullableString = Schema.optionalKey(NullableString)
|
|
9
|
+
const OptionalNullableNumber = Schema.optionalKey(Schema.NullOr(Schema.Number))
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
const RawCheckContextSchema = Schema.Union([RawCheckRunSchema, RawStatusContextSchema])
|
|
11
|
+
const RawCheckContextSchema = Schema.Union([
|
|
12
|
+
Schema.Struct({
|
|
13
|
+
__typename: Schema.tag("CheckRun"),
|
|
14
|
+
name: OptionalNullableString,
|
|
15
|
+
status: OptionalNullableString,
|
|
16
|
+
conclusion: OptionalNullableString,
|
|
17
|
+
}),
|
|
18
|
+
Schema.Struct({
|
|
19
|
+
__typename: Schema.tag("StatusContext"),
|
|
20
|
+
context: OptionalNullableString,
|
|
21
|
+
state: OptionalNullableString,
|
|
22
|
+
}),
|
|
23
|
+
]).pipe(Schema.toTaggedUnion("__typename"))
|
|
25
24
|
|
|
26
25
|
const RawAuthorSchema = Schema.Struct({ login: Schema.String })
|
|
27
26
|
const RawRepositorySchema = Schema.Struct({ nameWithOwner: Schema.String })
|
|
@@ -30,6 +29,10 @@ const RawLabelSchema = Schema.Struct({
|
|
|
30
29
|
color: OptionalNullableString,
|
|
31
30
|
})
|
|
32
31
|
|
|
32
|
+
const RawStatusCheckRollupSchema = Schema.Struct({
|
|
33
|
+
contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
|
|
34
|
+
})
|
|
35
|
+
|
|
33
36
|
const RawPullRequestSummaryFields = {
|
|
34
37
|
number: Schema.Number,
|
|
35
38
|
title: Schema.String,
|
|
@@ -46,7 +49,10 @@ const RawPullRequestSummaryFields = {
|
|
|
46
49
|
repository: RawRepositorySchema,
|
|
47
50
|
} as const
|
|
48
51
|
|
|
49
|
-
const RawPullRequestSummaryNodeSchema = Schema.Struct(
|
|
52
|
+
const RawPullRequestSummaryNodeSchema = Schema.Struct({
|
|
53
|
+
...RawPullRequestSummaryFields,
|
|
54
|
+
statusCheckRollup: Schema.optionalKey(Schema.NullOr(RawStatusCheckRollupSchema)),
|
|
55
|
+
})
|
|
50
56
|
|
|
51
57
|
const RawPullRequestNodeSchema = Schema.Struct({
|
|
52
58
|
...RawPullRequestSummaryFields,
|
|
@@ -55,9 +61,15 @@ const RawPullRequestNodeSchema = Schema.Struct({
|
|
|
55
61
|
additions: Schema.Number,
|
|
56
62
|
deletions: Schema.Number,
|
|
57
63
|
changedFiles: Schema.Number,
|
|
58
|
-
statusCheckRollup: Schema.
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
statusCheckRollup: Schema.optionalKey(Schema.NullOr(RawStatusCheckRollupSchema)),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const PullRequestDetailResponseSchema = Schema.Struct({
|
|
68
|
+
data: Schema.Struct({
|
|
69
|
+
repository: Schema.NullOr(Schema.Struct({
|
|
70
|
+
pullRequest: Schema.NullOr(RawPullRequestNodeSchema),
|
|
71
|
+
})),
|
|
72
|
+
}),
|
|
61
73
|
})
|
|
62
74
|
|
|
63
75
|
const PageInfoSchema = Schema.Struct({
|
|
@@ -75,6 +87,17 @@ const SearchResponseSchema = <Item extends Schema.Top>(item: Item) =>
|
|
|
75
87
|
}),
|
|
76
88
|
})
|
|
77
89
|
|
|
90
|
+
const RepositoryPullRequestsResponseSchema = Schema.Struct({
|
|
91
|
+
data: Schema.Struct({
|
|
92
|
+
repository: Schema.NullOr(Schema.Struct({
|
|
93
|
+
pullRequests: Schema.Struct({
|
|
94
|
+
nodes: Schema.Array(Schema.NullOr(RawPullRequestSummaryNodeSchema)),
|
|
95
|
+
pageInfo: PageInfoSchema,
|
|
96
|
+
}),
|
|
97
|
+
})),
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
|
|
78
101
|
const ViewerSchema = Schema.Struct({ login: Schema.String })
|
|
79
102
|
|
|
80
103
|
const MergeInfoResponseSchema = Schema.Struct({
|
|
@@ -88,22 +111,27 @@ const MergeInfoResponseSchema = Schema.Struct({
|
|
|
88
111
|
statusCheckRollup: Schema.Array(RawCheckContextSchema),
|
|
89
112
|
})
|
|
90
113
|
|
|
91
|
-
const DiffCommentSideSchema = Schema.Union([Schema.Literal("LEFT"), Schema.Literal("RIGHT")])
|
|
92
|
-
|
|
93
114
|
const PullRequestCommentSchema = Schema.Struct({
|
|
94
|
-
id: Schema.
|
|
115
|
+
id: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
|
|
95
116
|
node_id: OptionalNullableString,
|
|
96
117
|
body: OptionalNullableString,
|
|
97
118
|
html_url: OptionalNullableString,
|
|
98
119
|
url: OptionalNullableString,
|
|
99
120
|
created_at: OptionalNullableString,
|
|
100
|
-
user: Schema.
|
|
121
|
+
user: Schema.optionalKey(Schema.NullOr(Schema.Struct({
|
|
101
122
|
login: OptionalNullableString,
|
|
102
123
|
}))),
|
|
103
124
|
path: OptionalNullableString,
|
|
104
125
|
line: OptionalNullableNumber,
|
|
105
126
|
original_line: OptionalNullableNumber,
|
|
106
|
-
side: Schema.
|
|
127
|
+
side: Schema.optionalKey(Schema.NullOr(DiffCommentSide)),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const PullRequestFileSchema = Schema.Struct({
|
|
131
|
+
filename: Schema.String,
|
|
132
|
+
previous_filename: OptionalNullableString,
|
|
133
|
+
status: OptionalNullableString,
|
|
134
|
+
patch: OptionalNullableString,
|
|
107
135
|
})
|
|
108
136
|
|
|
109
137
|
const CommentsResponseSchema = Schema.Union([
|
|
@@ -111,6 +139,11 @@ const CommentsResponseSchema = Schema.Union([
|
|
|
111
139
|
Schema.Array(Schema.Array(PullRequestCommentSchema)),
|
|
112
140
|
])
|
|
113
141
|
|
|
142
|
+
const PullRequestFilesResponseSchema = Schema.Union([
|
|
143
|
+
Schema.Array(PullRequestFileSchema),
|
|
144
|
+
Schema.Array(Schema.Array(PullRequestFileSchema)),
|
|
145
|
+
])
|
|
146
|
+
|
|
114
147
|
const RepoLabelsResponseSchema = Schema.Array(Schema.Struct({
|
|
115
148
|
name: Schema.String,
|
|
116
149
|
color: Schema.String,
|
|
@@ -120,6 +153,7 @@ type RawPullRequestSummaryNode = Schema.Schema.Type<typeof RawPullRequestSummary
|
|
|
120
153
|
type RawPullRequestNode = Schema.Schema.Type<typeof RawPullRequestNodeSchema>
|
|
121
154
|
type RawCheckContext = Schema.Schema.Type<typeof RawCheckContextSchema>
|
|
122
155
|
type RawPullRequestComment = Schema.Schema.Type<typeof PullRequestCommentSchema>
|
|
156
|
+
type RawPullRequestFile = Schema.Schema.Type<typeof PullRequestFileSchema>
|
|
123
157
|
|
|
124
158
|
type SearchResponse<Item> = {
|
|
125
159
|
readonly data: {
|
|
@@ -133,11 +167,41 @@ type SearchResponse<Item> = {
|
|
|
133
167
|
}
|
|
134
168
|
}
|
|
135
169
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
170
|
+
type PullRequestConnection<Item> = {
|
|
171
|
+
readonly nodes: readonly (Item | null)[]
|
|
172
|
+
readonly pageInfo: {
|
|
173
|
+
readonly hasNextPage: boolean
|
|
174
|
+
readonly endCursor: string | null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const STATUS_CHECK_FRAGMENT = `
|
|
179
|
+
statusCheckRollup {
|
|
180
|
+
contexts(first: 100) {
|
|
181
|
+
nodes {
|
|
182
|
+
__typename
|
|
183
|
+
... on CheckRun { name status conclusion }
|
|
184
|
+
... on StatusContext { context state }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}`
|
|
188
|
+
|
|
189
|
+
const SUMMARY_FIELDS_FRAGMENT = `
|
|
190
|
+
number
|
|
191
|
+
title
|
|
192
|
+
isDraft
|
|
193
|
+
reviewDecision
|
|
194
|
+
autoMergeRequest { enabledAt }
|
|
195
|
+
state
|
|
196
|
+
merged
|
|
197
|
+
createdAt
|
|
198
|
+
closedAt
|
|
199
|
+
url
|
|
200
|
+
author { login }
|
|
201
|
+
headRefOid
|
|
202
|
+
repository { nameWithOwner }${STATUS_CHECK_FRAGMENT}`
|
|
203
|
+
|
|
204
|
+
const DETAIL_FIELDS_FRAGMENT = `
|
|
141
205
|
number
|
|
142
206
|
title
|
|
143
207
|
body
|
|
@@ -155,16 +219,13 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
|
155
219
|
author { login }
|
|
156
220
|
headRefOid
|
|
157
221
|
repository { nameWithOwner }
|
|
158
|
-
labels(first: 20) { nodes { name color } }
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
222
|
+
labels(first: 20) { nodes { name color } }${STATUS_CHECK_FRAGMENT}`
|
|
223
|
+
|
|
224
|
+
const pullRequestSearchQuery = `
|
|
225
|
+
query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
226
|
+
search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
|
|
227
|
+
nodes {
|
|
228
|
+
... on PullRequest {${DETAIL_FIELDS_FRAGMENT}
|
|
168
229
|
}
|
|
169
230
|
}
|
|
170
231
|
pageInfo { hasNextPage endCursor }
|
|
@@ -172,24 +233,20 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
|
172
233
|
}
|
|
173
234
|
`
|
|
174
235
|
|
|
236
|
+
const pullRequestDetailQuery = `
|
|
237
|
+
query PullRequest($owner: String!, $name: String!, $number: Int!) {
|
|
238
|
+
repository(owner: $owner, name: $name) {
|
|
239
|
+
pullRequest(number: $number) {${DETAIL_FIELDS_FRAGMENT}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
`
|
|
244
|
+
|
|
175
245
|
const pullRequestSummarySearchQuery = `
|
|
176
246
|
query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
177
247
|
search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
|
|
178
248
|
nodes {
|
|
179
|
-
... on PullRequest {
|
|
180
|
-
number
|
|
181
|
-
title
|
|
182
|
-
isDraft
|
|
183
|
-
reviewDecision
|
|
184
|
-
autoMergeRequest { enabledAt }
|
|
185
|
-
state
|
|
186
|
-
merged
|
|
187
|
-
createdAt
|
|
188
|
-
closedAt
|
|
189
|
-
url
|
|
190
|
-
author { login }
|
|
191
|
-
headRefOid
|
|
192
|
-
repository { nameWithOwner }
|
|
249
|
+
... on PullRequest {${SUMMARY_FIELDS_FRAGMENT}
|
|
193
250
|
}
|
|
194
251
|
}
|
|
195
252
|
pageInfo { hasNextPage endCursor }
|
|
@@ -197,6 +254,18 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
|
197
254
|
}
|
|
198
255
|
`
|
|
199
256
|
|
|
257
|
+
const repositoryPullRequestsQuery = `
|
|
258
|
+
query RepositoryPullRequests($owner: String!, $name: String!, $first: Int!, $after: String) {
|
|
259
|
+
repository(owner: $owner, name: $name) {
|
|
260
|
+
pullRequests(states: OPEN, first: $first, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
261
|
+
nodes {${SUMMARY_FIELDS_FRAGMENT}
|
|
262
|
+
}
|
|
263
|
+
pageInfo { hasNextPage endCursor }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
`
|
|
268
|
+
|
|
200
269
|
const normalizeDate = (value: string | null | undefined) => {
|
|
201
270
|
if (!value || value.startsWith("0001-01-01")) return null
|
|
202
271
|
return new Date(value)
|
|
@@ -205,13 +274,13 @@ const normalizeDate = (value: string | null | undefined) => {
|
|
|
205
274
|
const getPullRequestState = (item: { readonly state: string; readonly merged: boolean }): PullRequestItem["state"] =>
|
|
206
275
|
item.merged ? "merged" : item.state.toLowerCase() === "open" ? "open" : "closed"
|
|
207
276
|
|
|
208
|
-
const REVIEW_STATUS_BY_DECISION: Record<string,
|
|
277
|
+
const REVIEW_STATUS_BY_DECISION: Record<string, ReviewStatus> = {
|
|
209
278
|
APPROVED: "approved",
|
|
210
279
|
CHANGES_REQUESTED: "changes",
|
|
211
280
|
REVIEW_REQUIRED: "review",
|
|
212
281
|
}
|
|
213
282
|
|
|
214
|
-
const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }):
|
|
283
|
+
const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): ReviewStatus => {
|
|
215
284
|
if (item.isDraft) return "draft"
|
|
216
285
|
if (item.reviewDecision) return REVIEW_STATUS_BY_DECISION[item.reviewDecision] ?? "none"
|
|
217
286
|
return "none"
|
|
@@ -239,19 +308,24 @@ const normalizeCheckStatus = (raw: string | null | undefined): CheckItem["status
|
|
|
239
308
|
const normalizeCheckConclusion = (raw: string | null | undefined): CheckItem["conclusion"] =>
|
|
240
309
|
raw ? CHECK_CONCLUSION_BY_RAW[raw] ?? null : null
|
|
241
310
|
|
|
242
|
-
const getContextStatus = (context: RawCheckContext): CheckItem["status"] =>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
311
|
+
const getContextStatus = (context: RawCheckContext): CheckItem["status"] =>
|
|
312
|
+
RawCheckContextSchema.match(context, {
|
|
313
|
+
CheckRun: (run) => normalizeCheckStatus(run.status),
|
|
314
|
+
StatusContext: (status) => status.state === "PENDING" ? "in_progress" : "completed",
|
|
315
|
+
})
|
|
247
316
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return null
|
|
317
|
+
const STATUS_CONTEXT_CONCLUSION: Record<string, NonNullable<CheckItem["conclusion"]>> = {
|
|
318
|
+
SUCCESS: "success",
|
|
319
|
+
FAILURE: "failure",
|
|
320
|
+
ERROR: "failure",
|
|
253
321
|
}
|
|
254
322
|
|
|
323
|
+
const getContextConclusion = (context: RawCheckContext): CheckItem["conclusion"] =>
|
|
324
|
+
RawCheckContextSchema.match(context, {
|
|
325
|
+
CheckRun: (run) => normalizeCheckConclusion(run.conclusion),
|
|
326
|
+
StatusContext: (status) => (status.state ? STATUS_CONTEXT_CONCLUSION[status.state] : null) ?? null,
|
|
327
|
+
})
|
|
328
|
+
|
|
255
329
|
const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
|
|
256
330
|
if (contexts.length === 0) {
|
|
257
331
|
return { checkStatus: "none", checkSummary: null, checks: [] }
|
|
@@ -294,28 +368,31 @@ const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<Pu
|
|
|
294
368
|
return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
|
|
295
369
|
}
|
|
296
370
|
|
|
297
|
-
const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestItem =>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
371
|
+
const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestItem => {
|
|
372
|
+
const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
|
|
373
|
+
return {
|
|
374
|
+
repository: item.repository.nameWithOwner,
|
|
375
|
+
author: item.author.login,
|
|
376
|
+
headRefOid: item.headRefOid,
|
|
377
|
+
number: item.number,
|
|
378
|
+
title: item.title,
|
|
379
|
+
body: "",
|
|
380
|
+
labels: [],
|
|
381
|
+
additions: 0,
|
|
382
|
+
deletions: 0,
|
|
383
|
+
changedFiles: 0,
|
|
384
|
+
state: getPullRequestState(item),
|
|
385
|
+
reviewStatus: getReviewStatus(item),
|
|
386
|
+
checkStatus: checkInfo.checkStatus,
|
|
387
|
+
checkSummary: checkInfo.checkSummary,
|
|
388
|
+
checks: checkInfo.checks,
|
|
389
|
+
autoMergeEnabled: item.autoMergeRequest !== null,
|
|
390
|
+
detailLoaded: false,
|
|
391
|
+
createdAt: new Date(item.createdAt),
|
|
392
|
+
closedAt: normalizeDate(item.closedAt),
|
|
393
|
+
url: item.url,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
319
396
|
|
|
320
397
|
const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
|
|
321
398
|
const checkInfo = getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
|
|
@@ -336,10 +413,21 @@ const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
|
|
|
336
413
|
}
|
|
337
414
|
}
|
|
338
415
|
|
|
339
|
-
const searchQuery = (mode: PullRequestQueueMode, author: string
|
|
416
|
+
const searchQuery = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
|
|
417
|
+
const sort = mode === "repository" ? "sort:updated-desc" : "sort:created-desc"
|
|
418
|
+
return `${pullRequestQueueSearchQualifier(mode, author, repository)} is:pr is:open ${sort}`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const pullRequestPage = <Item>(connection: PullRequestConnection<Item>, parse: (node: Item) => PullRequestItem): PullRequestPage => ({
|
|
422
|
+
items: connection.nodes.flatMap((node) => node ? [parse(node)] : []),
|
|
423
|
+
endCursor: connection.pageInfo.endCursor,
|
|
424
|
+
hasNextPage: connection.pageInfo.hasNextPage && connection.pageInfo.endCursor !== null,
|
|
425
|
+
})
|
|
340
426
|
|
|
341
|
-
const
|
|
342
|
-
[
|
|
427
|
+
const repositoryParts = (repository: string) => {
|
|
428
|
+
const [owner, name] = repository.split("/")
|
|
429
|
+
return owner && name ? { owner, name } : null
|
|
430
|
+
}
|
|
343
431
|
|
|
344
432
|
const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
|
|
345
433
|
const line = comment.line ?? comment.original_line
|
|
@@ -357,15 +445,40 @@ const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestRev
|
|
|
357
445
|
}
|
|
358
446
|
|
|
359
447
|
const parsePullRequestComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestReviewComment[] => {
|
|
360
|
-
|
|
361
|
-
? response as readonly (readonly RawPullRequestComment[])[]
|
|
362
|
-
: [response as readonly RawPullRequestComment[]]
|
|
363
|
-
return pages.flatMap((page) => page.flatMap((comment) => {
|
|
448
|
+
return flattenSlurpedPages(response).flatMap((comment) => {
|
|
364
449
|
const parsed = parsePullRequestComment(comment)
|
|
365
450
|
return parsed ? [parsed] : []
|
|
366
|
-
})
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const flattenSlurpedPages = <Item>(response: readonly Item[] | readonly (readonly Item[])[]): readonly Item[] =>
|
|
455
|
+
Array.isArray(response[0]) ? (response as readonly (readonly Item[])[]).flat() : response as readonly Item[]
|
|
456
|
+
|
|
457
|
+
const parsePullRequestFiles = (response: Schema.Schema.Type<typeof PullRequestFilesResponseSchema>): readonly RawPullRequestFile[] =>
|
|
458
|
+
flattenSlurpedPages(response)
|
|
459
|
+
|
|
460
|
+
const diffPath = (path: string) => /\s|"/.test(path) ? JSON.stringify(path) : path
|
|
461
|
+
|
|
462
|
+
const prefixedDiffPath = (prefix: "a" | "b", path: string) => diffPath(`${prefix}/${path}`)
|
|
463
|
+
|
|
464
|
+
const fileHeaderPatch = (file: RawPullRequestFile) => {
|
|
465
|
+
const oldPath = file.previous_filename ?? file.filename
|
|
466
|
+
const newPath = file.filename
|
|
467
|
+
const oldRef = file.status === "added" ? "/dev/null" : prefixedDiffPath("a", oldPath)
|
|
468
|
+
const newRef = file.status === "removed" ? "/dev/null" : prefixedDiffPath("b", newPath)
|
|
469
|
+
const lines = [
|
|
470
|
+
`diff --git ${prefixedDiffPath("a", oldPath)} ${prefixedDiffPath("b", newPath)}`,
|
|
471
|
+
...(file.status === "renamed" && file.previous_filename ? [`rename from ${oldPath}`, `rename to ${newPath}`] : []),
|
|
472
|
+
`--- ${oldRef}`,
|
|
473
|
+
`+++ ${newRef}`,
|
|
474
|
+
]
|
|
475
|
+
if (file.patch) lines.push(file.patch.trimEnd())
|
|
476
|
+
return lines.join("\n")
|
|
367
477
|
}
|
|
368
478
|
|
|
479
|
+
export const pullRequestFilesToPatch = (files: readonly RawPullRequestFile[]) =>
|
|
480
|
+
files.map(fileHeaderPatch).join("\n")
|
|
481
|
+
|
|
369
482
|
const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullRequestReviewComment => ({
|
|
370
483
|
id: `created:${input.repository}:${input.number}:${input.path}:${input.side}:${input.line}:${Date.now()}`,
|
|
371
484
|
path: input.path,
|
|
@@ -379,19 +492,21 @@ const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullReque
|
|
|
379
492
|
|
|
380
493
|
export type GitHubError = CommandError | JsonParseError | Schema.SchemaError
|
|
381
494
|
|
|
382
|
-
const MERGEABLE_BY_RAW: Record<string,
|
|
495
|
+
const MERGEABLE_BY_RAW: Record<string, Mergeable> = {
|
|
383
496
|
MERGEABLE: "mergeable",
|
|
384
497
|
CONFLICTING: "conflicting",
|
|
385
498
|
}
|
|
386
499
|
|
|
387
|
-
const normalizeMergeable = (value: string):
|
|
500
|
+
const normalizeMergeable = (value: string): Mergeable =>
|
|
388
501
|
MERGEABLE_BY_RAW[value] ?? "unknown"
|
|
389
502
|
|
|
390
503
|
export class GitHubService extends Context.Service<GitHubService, {
|
|
391
|
-
readonly listOpenPullRequests: (mode: PullRequestQueueMode) => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
392
|
-
readonly
|
|
504
|
+
readonly listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
505
|
+
readonly listOpenPullRequestPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>
|
|
506
|
+
readonly listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
507
|
+
readonly getPullRequestDetails: (repository: string, number: number) => Effect.Effect<PullRequestItem, GitHubError>
|
|
393
508
|
readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
|
|
394
|
-
readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string,
|
|
509
|
+
readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, GitHubError>
|
|
395
510
|
readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
|
|
396
511
|
readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
|
|
397
512
|
readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
|
|
@@ -407,55 +522,112 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
407
522
|
Effect.gen(function*() {
|
|
408
523
|
const command = yield* CommandRunner
|
|
409
524
|
|
|
410
|
-
const
|
|
525
|
+
const ghJson = <S extends Schema.Top>(label: string, schema: S, args: readonly string[]) =>
|
|
526
|
+
command.runSchema(schema, "gh", args).pipe(Effect.withSpan(`GitHubService.${label}`))
|
|
527
|
+
|
|
528
|
+
const ghVoid = (label: string, args: readonly string[]) =>
|
|
529
|
+
command.run("gh", args).pipe(Effect.withSpan(`GitHubService.${label}`), Effect.asVoid)
|
|
530
|
+
|
|
531
|
+
const searchPage = <Item extends Schema.Top>(label: string, query: string, schema: Item, parse: (node: Item["Type"]) => PullRequestItem) => {
|
|
411
532
|
const responseSchema = SearchResponseSchema(schema)
|
|
412
|
-
return Effect.fn(`GitHubService.${label}`)(function*(
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
"-F", `searchQuery=${searchQuery(mode, config.author)}`,
|
|
422
|
-
"-F", `first=${pageSize}`,
|
|
423
|
-
...(cursor ? ["-F", `after=${cursor}`] : []),
|
|
424
|
-
])
|
|
425
|
-
|
|
426
|
-
for (const node of response.data.search.nodes) {
|
|
427
|
-
if (node) pullRequests.push(parse(node))
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (!response.data.search.pageInfo.hasNextPage) break
|
|
431
|
-
cursor = response.data.search.pageInfo.endCursor
|
|
432
|
-
if (!cursor) break
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return sortNewestFirst(pullRequests)
|
|
533
|
+
return Effect.fn(`GitHubService.${label}`)(function*(input: ListPullRequestPageInput) {
|
|
534
|
+
const response: SearchResponse<Item["Type"]> = yield* command.runSchema(responseSchema, "gh", [
|
|
535
|
+
"api", "graphql",
|
|
536
|
+
"-f", `query=${query}`,
|
|
537
|
+
"-F", `searchQuery=${searchQuery(input.mode, config.author, input.repository)}`,
|
|
538
|
+
"-F", `first=${input.pageSize}`,
|
|
539
|
+
...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
|
|
540
|
+
])
|
|
541
|
+
return pullRequestPage(response.data.search, parse)
|
|
436
542
|
})
|
|
437
543
|
}
|
|
438
544
|
|
|
439
|
-
const
|
|
440
|
-
const
|
|
545
|
+
const listOpenPullRequestSearchPage = searchPage("listOpenPullRequestSearchPage", pullRequestSummarySearchQuery, RawPullRequestSummaryNodeSchema, parsePullRequestSummary)
|
|
546
|
+
const listOpenPullRequestDetailsPage = searchPage("listOpenPullRequestDetailsPage", pullRequestSearchQuery, RawPullRequestNodeSchema, parsePullRequest)
|
|
547
|
+
|
|
548
|
+
const listRepositoryPullRequestPage = Effect.fn("GitHubService.listRepositoryPullRequestPage")(function*(input: ListPullRequestPageInput) {
|
|
549
|
+
if (!input.repository) return { items: [], endCursor: null, hasNextPage: false } satisfies PullRequestPage
|
|
550
|
+
const repo = repositoryParts(input.repository)
|
|
551
|
+
if (!repo) {
|
|
552
|
+
return yield* new CommandError({ command: "gh", args: [], detail: `Invalid repository: ${input.repository}`, cause: input.repository })
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const response = yield* command.runSchema(RepositoryPullRequestsResponseSchema, "gh", [
|
|
556
|
+
"api", "graphql",
|
|
557
|
+
"-f", `query=${repositoryPullRequestsQuery}`,
|
|
558
|
+
"-F", `owner=${repo.owner}`,
|
|
559
|
+
"-F", `name=${repo.name}`,
|
|
560
|
+
"-F", `first=${input.pageSize}`,
|
|
561
|
+
...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
|
|
562
|
+
])
|
|
563
|
+
const connection = response.data.repository?.pullRequests
|
|
564
|
+
if (!connection) {
|
|
565
|
+
return yield* new CommandError({ command: "gh", args: [], detail: `Repository not found: ${input.repository}`, cause: input.repository })
|
|
566
|
+
}
|
|
567
|
+
return pullRequestPage(connection, parsePullRequestSummary)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const listOpenPullRequestPage = Effect.fn("GitHubService.listOpenPullRequestPage")(function*(input: ListPullRequestPageInput) {
|
|
571
|
+
const pageSize = Math.max(1, Math.min(100, input.pageSize))
|
|
572
|
+
const pageInput = { ...input, pageSize }
|
|
573
|
+
if (pageInput.mode === "repository" && pageInput.repository) return yield* listRepositoryPullRequestPage(pageInput)
|
|
574
|
+
return yield* listOpenPullRequestSearchPage(pageInput)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const paginatePages = Effect.fn("GitHubService.paginatePages")(function*(mode: PullRequestQueueMode, repository: string | null, loadPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>) {
|
|
578
|
+
const pullRequests: PullRequestItem[] = []
|
|
579
|
+
let cursor: string | null = null
|
|
441
580
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
581
|
+
while (pullRequests.length < config.prFetchLimit) {
|
|
582
|
+
const page: PullRequestPage = yield* loadPage({ mode, repository, cursor, pageSize: Math.min(100, config.prFetchLimit - pullRequests.length) })
|
|
583
|
+
pullRequests.push(...page.items)
|
|
584
|
+
if (!page.hasNextPage || !page.endCursor) break
|
|
585
|
+
cursor = page.endCursor
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return pullRequests
|
|
445
589
|
})
|
|
446
590
|
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
591
|
+
const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*(mode: PullRequestQueueMode, repository: string | null) {
|
|
592
|
+
return yield* paginatePages(mode, repository, listOpenPullRequestPage)
|
|
593
|
+
})
|
|
594
|
+
const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function*(mode: PullRequestQueueMode, repository: string | null) {
|
|
595
|
+
return yield* paginatePages(mode, repository, listOpenPullRequestDetailsPage)
|
|
450
596
|
})
|
|
451
597
|
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
|
|
598
|
+
const getPullRequestDetails = Effect.fn("GitHubService.getPullRequestDetails")(function*(repository: string, number: number) {
|
|
599
|
+
const repo = repositoryParts(repository)
|
|
600
|
+
if (!repo) {
|
|
601
|
+
return yield* new CommandError({ command: "gh", args: [], detail: `Invalid repository: ${repository}`, cause: repository })
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const response = yield* command.runSchema(PullRequestDetailResponseSchema, "gh", [
|
|
605
|
+
"api", "graphql",
|
|
606
|
+
"-f", `query=${pullRequestDetailQuery}`,
|
|
607
|
+
"-F", `owner=${repo.owner}`,
|
|
608
|
+
"-F", `name=${repo.name}`,
|
|
609
|
+
"-F", `number=${number}`,
|
|
455
610
|
])
|
|
456
|
-
|
|
611
|
+
const pullRequest = response.data.repository?.pullRequest
|
|
612
|
+
if (!pullRequest) {
|
|
613
|
+
return yield* new CommandError({ command: "gh", args: [], detail: `Pull request not found: ${repository}#${number}`, cause: `${repository}#${number}` })
|
|
614
|
+
}
|
|
615
|
+
return parsePullRequest(pullRequest)
|
|
457
616
|
})
|
|
458
617
|
|
|
618
|
+
const getAuthenticatedUser = () =>
|
|
619
|
+
ghJson("getAuthenticatedUser", ViewerSchema, ["api", "user"]).pipe(Effect.map((viewer) => viewer.login))
|
|
620
|
+
|
|
621
|
+
const getPullRequestDiff = (repository: string, number: number) =>
|
|
622
|
+
ghJson("getPullRequestDiff", PullRequestFilesResponseSchema, [
|
|
623
|
+
"api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/files`,
|
|
624
|
+
]).pipe(Effect.map((response) => pullRequestFilesToPatch(parsePullRequestFiles(response))))
|
|
625
|
+
|
|
626
|
+
const listPullRequestComments = (repository: string, number: number) =>
|
|
627
|
+
ghJson("listPullRequestComments", CommentsResponseSchema, [
|
|
628
|
+
"api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/comments`,
|
|
629
|
+
]).pipe(Effect.map(parsePullRequestComments))
|
|
630
|
+
|
|
459
631
|
const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
|
|
460
632
|
const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
|
|
461
633
|
"pr", "view", String(number), "--repo", repository,
|
|
@@ -477,14 +649,11 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
477
649
|
} satisfies PullRequestMergeInfo
|
|
478
650
|
})
|
|
479
651
|
|
|
480
|
-
const mergePullRequest =
|
|
481
|
-
|
|
482
|
-
yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
|
|
483
|
-
})
|
|
652
|
+
const mergePullRequest = (repository: string, number: number, action: PullRequestMergeAction) =>
|
|
653
|
+
ghVoid("mergePullRequest", ["pr", "merge", String(number), "--repo", repository, ...getMergeActionDefinition(action).cliArgs])
|
|
484
654
|
|
|
485
|
-
const closePullRequest =
|
|
486
|
-
|
|
487
|
-
})
|
|
655
|
+
const closePullRequest = (repository: string, number: number) =>
|
|
656
|
+
ghVoid("closePullRequest", ["pr", "close", String(number), "--repo", repository])
|
|
488
657
|
|
|
489
658
|
const createPullRequestComment = Effect.fn("GitHubService.createPullRequestComment")(function*(input: CreatePullRequestCommentInput) {
|
|
490
659
|
const response = yield* command.runSchema(PullRequestCommentSchema, "gh", [
|
|
@@ -498,28 +667,25 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
498
667
|
return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
|
|
499
668
|
})
|
|
500
669
|
|
|
501
|
-
const toggleDraftStatus =
|
|
502
|
-
|
|
503
|
-
})
|
|
670
|
+
const toggleDraftStatus = (repository: string, number: number, isDraft: boolean) =>
|
|
671
|
+
ghVoid("toggleDraftStatus", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
504
672
|
|
|
505
|
-
const listRepoLabels =
|
|
506
|
-
|
|
673
|
+
const listRepoLabels = (repository: string) =>
|
|
674
|
+
ghJson("listRepoLabels", RepoLabelsResponseSchema, [
|
|
507
675
|
"label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
|
|
508
|
-
])
|
|
509
|
-
return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
|
|
510
|
-
})
|
|
676
|
+
]).pipe(Effect.map((labels) => labels.map((label) => ({ name: label.name, color: `#${label.color}` }))))
|
|
511
677
|
|
|
512
|
-
const addPullRequestLabel =
|
|
513
|
-
|
|
514
|
-
})
|
|
678
|
+
const addPullRequestLabel = (repository: string, number: number, label: string) =>
|
|
679
|
+
ghVoid("addPullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
|
|
515
680
|
|
|
516
|
-
const removePullRequestLabel =
|
|
517
|
-
|
|
518
|
-
})
|
|
681
|
+
const removePullRequestLabel = (repository: string, number: number, label: string) =>
|
|
682
|
+
ghVoid("removePullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
|
|
519
683
|
|
|
520
684
|
return GitHubService.of({
|
|
521
685
|
listOpenPullRequests,
|
|
686
|
+
listOpenPullRequestPage,
|
|
522
687
|
listOpenPullRequestDetails,
|
|
688
|
+
getPullRequestDetails,
|
|
523
689
|
getAuthenticatedUser,
|
|
524
690
|
getPullRequestDiff,
|
|
525
691
|
listPullRequestComments,
|