@kitlangton/ghui 0.1.7 → 0.1.9

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/src/config.ts CHANGED
@@ -1,9 +1,18 @@
1
- const parsePositiveInt = (value: string | undefined, fallback: number) => {
2
- const parsed = Number.parseInt(value ?? "", 10)
3
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
4
- }
1
+ import { Config, Effect } from "effect"
5
2
 
6
- export const config = {
7
- author: process.env.GHUI_AUTHOR?.trim() || "@me",
8
- prFetchLimit: parsePositiveInt(process.env.GHUI_PR_FETCH_LIMIT, 200),
9
- } as const
3
+ const positiveIntOr = (fallback: number) => (value: number) => Number.isFinite(value) && value > 0 ? value : fallback
4
+
5
+ const appConfig = Config.all({
6
+ author: Config.string("GHUI_AUTHOR").pipe(
7
+ Config.withDefault("@me"),
8
+ Config.map((value) => value.trim() || "@me"),
9
+ ),
10
+ prFetchLimit: Config.int("GHUI_PR_FETCH_LIMIT").pipe(
11
+ Config.withDefault(200),
12
+ Config.map(positiveIntOr(200)),
13
+ ),
14
+ })
15
+
16
+ export const config = Effect.runSync(Effect.gen(function*() {
17
+ return yield* appConfig
18
+ }))
package/src/domain.ts CHANGED
@@ -27,7 +27,24 @@ export interface PullRequestItem {
27
27
  readonly checkStatus: "passing" | "pending" | "failing" | "none"
28
28
  readonly checkSummary: string | null
29
29
  readonly checks: readonly CheckItem[]
30
+ readonly autoMergeEnabled: boolean
31
+ readonly detailLoaded: boolean
30
32
  readonly createdAt: Date
31
33
  readonly closedAt: Date | null
32
34
  readonly url: string
33
35
  }
36
+
37
+ export interface PullRequestMergeInfo {
38
+ readonly repository: string
39
+ readonly number: number
40
+ readonly title: string
41
+ readonly state: PullRequestState
42
+ readonly isDraft: boolean
43
+ readonly mergeable: "mergeable" | "conflicting" | "unknown"
44
+ readonly reviewStatus: PullRequestItem["reviewStatus"]
45
+ readonly checkStatus: PullRequestItem["checkStatus"]
46
+ readonly checkSummary: string | null
47
+ readonly autoMergeEnabled: boolean
48
+ }
49
+
50
+ export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
@@ -0,0 +1,90 @@
1
+ import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "./domain.js"
2
+
3
+ export interface MergeActionDefinition {
4
+ readonly action: PullRequestMergeAction
5
+ readonly title: string
6
+ readonly description: string
7
+ readonly cliArgs: readonly string[]
8
+ readonly pastTense: string
9
+ readonly danger?: boolean
10
+ readonly refreshOnSuccess?: boolean
11
+ readonly optimisticAutoMergeEnabled?: boolean
12
+ readonly isAvailable: (info: PullRequestMergeInfo) => boolean
13
+ }
14
+
15
+ const isCleanlyMergeable = (info: PullRequestMergeInfo) =>
16
+ info.state === "open" &&
17
+ !info.isDraft &&
18
+ info.mergeable === "mergeable" &&
19
+ info.reviewStatus !== "changes" &&
20
+ info.reviewStatus !== "review" &&
21
+ info.checkStatus !== "pending" &&
22
+ info.checkStatus !== "failing"
23
+
24
+ const mergeActionDefinitions = {
25
+ squash: {
26
+ action: "squash",
27
+ title: "Squash merge now",
28
+ description: "Merge this pull request and delete the branch.",
29
+ cliArgs: ["--squash", "--delete-branch"],
30
+ pastTense: "Merged",
31
+ refreshOnSuccess: true,
32
+ isAvailable: isCleanlyMergeable,
33
+ },
34
+ auto: {
35
+ action: "auto",
36
+ title: "Enable auto-merge",
37
+ description: "Squash merge automatically after GitHub requirements pass.",
38
+ cliArgs: ["--squash", "--auto", "--delete-branch"],
39
+ pastTense: "Enabled auto-merge",
40
+ optimisticAutoMergeEnabled: true,
41
+ isAvailable: (info) => info.state === "open" && !info.autoMergeEnabled && !info.isDraft && info.mergeable !== "conflicting",
42
+ },
43
+ "disable-auto": {
44
+ action: "disable-auto",
45
+ title: "Disable auto-merge",
46
+ description: "Cancel the pending GitHub auto-merge request.",
47
+ cliArgs: ["--disable-auto"],
48
+ pastTense: "Disabled auto-merge",
49
+ optimisticAutoMergeEnabled: false,
50
+ isAvailable: (info) => info.state === "open" && info.autoMergeEnabled,
51
+ },
52
+ admin: {
53
+ action: "admin",
54
+ title: "Admin override merge",
55
+ description: "Bypass unmet merge requirements with --admin.",
56
+ cliArgs: ["--squash", "--admin", "--delete-branch"],
57
+ pastTense: "Admin merged",
58
+ danger: true,
59
+ refreshOnSuccess: true,
60
+ isAvailable: (info) => info.state === "open" && !info.isDraft && info.mergeable !== "conflicting",
61
+ },
62
+ } as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
63
+
64
+ export const mergeActions = [
65
+ mergeActionDefinitions.squash,
66
+ mergeActionDefinitions.auto,
67
+ mergeActionDefinitions["disable-auto"],
68
+ mergeActionDefinitions.admin,
69
+ ] as const satisfies readonly MergeActionDefinition[]
70
+
71
+ export const availableMergeActions = (info: PullRequestMergeInfo | null): readonly MergeActionDefinition[] => {
72
+ if (!info) return []
73
+ return mergeActions.filter((action) => action.isAvailable(info))
74
+ }
75
+
76
+ export const getMergeActionDefinition = (action: PullRequestMergeAction): MergeActionDefinition =>
77
+ mergeActionDefinitions[action]
78
+
79
+ export const mergeInfoFromPullRequest = (pullRequest: PullRequestItem): PullRequestMergeInfo => ({
80
+ repository: pullRequest.repository,
81
+ number: pullRequest.number,
82
+ title: pullRequest.title,
83
+ state: pullRequest.state,
84
+ isDraft: pullRequest.reviewStatus === "draft",
85
+ mergeable: "unknown",
86
+ reviewStatus: pullRequest.reviewStatus,
87
+ checkStatus: pullRequest.checkStatus,
88
+ checkSummary: pullRequest.checkSummary,
89
+ autoMergeEnabled: pullRequest.autoMergeEnabled,
90
+ })
@@ -0,0 +1,46 @@
1
+ import { Config, Effect, Layer } from "effect"
2
+ import { FetchHttpClient } from "effect/unstable/http"
3
+ import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
4
+
5
+ const observabilityConfig = Config.all({
6
+ endpoint: Config.string("GHUI_OTLP_ENDPOINT").pipe(
7
+ Config.withDefault(""),
8
+ Config.map((value) => value.trim()),
9
+ ),
10
+ motelPort: Config.string("GHUI_MOTEL_PORT").pipe(
11
+ Config.withDefault(""),
12
+ Config.map((value) => value.trim()),
13
+ ),
14
+ })
15
+
16
+ const resource = {
17
+ serviceName: "ghui",
18
+ serviceVersion: "local",
19
+ }
20
+
21
+ export const Observability = {
22
+ layer: Layer.unwrap(Effect.gen(function*() {
23
+ const { endpoint, motelPort } = yield* observabilityConfig
24
+ const baseUrl = endpoint || (motelPort ? `http://127.0.0.1:${motelPort}` : null)
25
+
26
+ return baseUrl === null
27
+ ? Layer.empty
28
+ : Layer.merge(
29
+ OtlpTracer.layer({
30
+ url: `${baseUrl}/v1/traces`,
31
+ exportInterval: "500 millis",
32
+ shutdownTimeout: "1 second",
33
+ resource,
34
+ }),
35
+ OtlpLogger.layer({
36
+ url: `${baseUrl}/v1/logs`,
37
+ exportInterval: "500 millis",
38
+ shutdownTimeout: "1 second",
39
+ resource,
40
+ }),
41
+ ).pipe(
42
+ Layer.provide(OtlpSerialization.layerJson),
43
+ Layer.provide(FetchHttpClient.layer),
44
+ )
45
+ })),
46
+ } as const
@@ -49,7 +49,12 @@ export class CommandRunner extends Context.Service<CommandRunner, {
49
49
  )
50
50
 
51
51
  const run = Effect.fn("CommandRunner.run")(function*(command: string, args: readonly string[]) {
52
- const result = yield* runProcess(command, args)
52
+ const result = yield* runProcess(command, args).pipe(Effect.withSpan("ghui.command.runProcess", {
53
+ attributes: {
54
+ "process.command": command,
55
+ "process.argv.count": args.length,
56
+ },
57
+ }))
53
58
  if (result.exitCode !== 0) {
54
59
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
55
60
  return yield* new CommandError({ command, args: [...args], detail, cause: detail })
@@ -1,11 +1,25 @@
1
1
  import { Context, Effect, Layer } from "effect"
2
2
  import { config } from "../config.js"
3
- import type { CheckItem, PullRequestItem } from "../domain.js"
3
+ import type { CheckItem, PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
4
+ import { getMergeActionDefinition } from "../mergeActions.js"
4
5
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
5
6
 
6
- interface GitHubPullRequestNode {
7
+ interface GitHubPullRequestSummaryNode {
7
8
  readonly number: number
8
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
+ }
21
+
22
+ interface GitHubPullRequestNode extends GitHubPullRequestSummaryNode {
9
23
  readonly body: string
10
24
  readonly labels: {
11
25
  readonly nodes: readonly {
@@ -16,20 +30,11 @@ interface GitHubPullRequestNode {
16
30
  readonly additions: number
17
31
  readonly deletions: number
18
32
  readonly changedFiles: number
19
- readonly isDraft: boolean
20
- readonly reviewDecision: string | null
21
33
  readonly statusCheckRollup?: {
22
34
  readonly contexts: {
23
35
  readonly nodes: readonly GraphQLCheckContext[]
24
36
  }
25
37
  } | null
26
- readonly state: string
27
- readonly createdAt: string
28
- readonly closedAt?: string | null
29
- readonly url: string
30
- readonly repository: {
31
- readonly nameWithOwner: string
32
- }
33
38
  }
34
39
 
35
40
  type GraphQLCheckContext =
@@ -57,10 +62,33 @@ interface GraphQLSearchResponse {
57
62
  }
58
63
  }
59
64
 
65
+ interface GraphQLSearchSummaryResponse {
66
+ readonly data: {
67
+ readonly search: {
68
+ readonly nodes: readonly (GitHubPullRequestSummaryNode | null)[]
69
+ readonly pageInfo: {
70
+ readonly hasNextPage: boolean
71
+ readonly endCursor: string | null
72
+ }
73
+ }
74
+ }
75
+ }
76
+
60
77
  interface GitHubViewer {
61
78
  readonly login: string
62
79
  }
63
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
+
64
92
  const pullRequestSearchQuery = `
65
93
  query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
66
94
  search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
@@ -71,6 +99,7 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
71
99
  body
72
100
  isDraft
73
101
  reviewDecision
102
+ autoMergeRequest { enabledAt }
74
103
  additions
75
104
  deletions
76
105
  changedFiles
@@ -96,12 +125,34 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
96
125
  }
97
126
  `
98
127
 
128
+ const pullRequestSummarySearchQuery = `
129
+ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
130
+ search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
131
+ nodes {
132
+ ... on PullRequest {
133
+ number
134
+ title
135
+ isDraft
136
+ reviewDecision
137
+ autoMergeRequest { enabledAt }
138
+ state
139
+ createdAt
140
+ closedAt
141
+ url
142
+ repository { nameWithOwner }
143
+ }
144
+ }
145
+ pageInfo { hasNextPage endCursor }
146
+ }
147
+ }
148
+ `
149
+
99
150
  const normalizeDate = (value: string | null | undefined) => {
100
151
  if (!value || value.startsWith("0001-01-01")) return null
101
152
  return new Date(value)
102
153
  }
103
154
 
104
- const getReviewStatus = (item: GitHubPullRequestNode): PullRequestItem["reviewStatus"] => {
155
+ const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): PullRequestItem["reviewStatus"] => {
105
156
  if (item.isDraft) return "draft"
106
157
  if (item.reviewDecision === "APPROVED") return "approved"
107
158
  if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
@@ -139,8 +190,7 @@ const getContextConclusion = (context: GraphQLCheckContext): CheckItem["conclusi
139
190
  return null
140
191
  }
141
192
 
142
- const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
143
- const contexts = item.statusCheckRollup?.contexts.nodes ?? []
193
+ const getCheckInfoFromContexts = (contexts: readonly GraphQLCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
144
194
  if (contexts.length === 0) {
145
195
  return { checkStatus: "none", checkSummary: null, checks: [] }
146
196
  }
@@ -182,6 +232,9 @@ const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "check
182
232
  return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
183
233
  }
184
234
 
235
+ const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> =>
236
+ getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
237
+
185
238
  const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
186
239
  const checkInfo = getCheckInfo(item)
187
240
 
@@ -202,20 +255,52 @@ const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
202
255
  checkStatus: checkInfo.checkStatus,
203
256
  checkSummary: checkInfo.checkSummary,
204
257
  checks: checkInfo.checks,
258
+ autoMergeEnabled: item.autoMergeRequest !== null,
259
+ detailLoaded: true,
205
260
  createdAt: new Date(item.createdAt),
206
261
  closedAt: normalizeDate(item.closedAt),
207
262
  url: item.url,
208
263
  }
209
264
  }
210
265
 
266
+ const parsePullRequestSummary = (item: GitHubPullRequestSummaryNode): PullRequestItem => ({
267
+ repository: item.repository.nameWithOwner,
268
+ number: item.number,
269
+ title: item.title,
270
+ body: "",
271
+ labels: [],
272
+ additions: 0,
273
+ deletions: 0,
274
+ changedFiles: 0,
275
+ state: item.state.toLowerCase() === "open" ? "open" : "closed",
276
+ reviewStatus: getReviewStatus(item),
277
+ checkStatus: "none",
278
+ checkSummary: null,
279
+ checks: [],
280
+ autoMergeEnabled: item.autoMergeRequest !== null,
281
+ detailLoaded: false,
282
+ createdAt: new Date(item.createdAt),
283
+ closedAt: normalizeDate(item.closedAt),
284
+ url: item.url,
285
+ })
286
+
211
287
  const searchQuery = (author: string) => `author:${author} is:pr is:open sort:created-desc`
212
288
 
213
289
  type GitHubError = CommandError | JsonParseError
214
290
 
291
+ const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] => {
292
+ if (value === "MERGEABLE") return "mergeable"
293
+ if (value === "CONFLICTING") return "conflicting"
294
+ return "unknown"
295
+ }
296
+
215
297
  export class GitHubService extends Context.Service<GitHubService, {
216
298
  readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
299
+ readonly listOpenPullRequestDetails: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
217
300
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
218
301
  readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
302
+ readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
303
+ readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
219
304
  readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
220
305
  readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
221
306
  readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
@@ -230,6 +315,32 @@ export class GitHubService extends Context.Service<GitHubService, {
230
315
  const pullRequests: PullRequestItem[] = []
231
316
  let cursor: string | null = null
232
317
 
318
+ while (pullRequests.length < config.prFetchLimit) {
319
+ const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
320
+ const response: GraphQLSearchSummaryResponse = yield* command.runJson<GraphQLSearchSummaryResponse>("gh", [
321
+ "api", "graphql",
322
+ "-f", `query=${pullRequestSummarySearchQuery}`,
323
+ "-F", `searchQuery=${searchQuery(config.author)}`,
324
+ "-F", `first=${pageSize}`,
325
+ ...(cursor ? ["-F", `after=${cursor}`] : []),
326
+ ])
327
+
328
+ for (const node of response.data.search.nodes) {
329
+ if (node) pullRequests.push(parsePullRequestSummary(node))
330
+ }
331
+
332
+ if (!response.data.search.pageInfo.hasNextPage) break
333
+ cursor = response.data.search.pageInfo.endCursor
334
+ if (!cursor) break
335
+ }
336
+
337
+ return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
338
+ })
339
+
340
+ const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function*() {
341
+ const pullRequests: PullRequestItem[] = []
342
+ let cursor: string | null = null
343
+
233
344
  while (pullRequests.length < config.prFetchLimit) {
234
345
  const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
235
346
  const response: GraphQLSearchResponse = yield* command.runJson<GraphQLSearchResponse>("gh", [
@@ -262,6 +373,32 @@ export class GitHubService extends Context.Service<GitHubService, {
262
373
  return result.stdout
263
374
  })
264
375
 
376
+ const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
377
+ const info = yield* command.runJson<GitHubMergeInfoResponse>("gh", [
378
+ "pr", "view", String(number), "--repo", repository,
379
+ "--json", "number,title,state,isDraft,mergeable,reviewDecision,autoMergeRequest,statusCheckRollup",
380
+ ])
381
+ const checkInfo = getCheckInfoFromContexts(info.statusCheckRollup)
382
+
383
+ return {
384
+ repository,
385
+ number: info.number,
386
+ title: info.title,
387
+ state: info.state.toLowerCase() === "open" ? "open" : "closed",
388
+ isDraft: info.isDraft,
389
+ mergeable: normalizeMergeable(info.mergeable),
390
+ reviewStatus: getReviewStatus(info),
391
+ checkStatus: checkInfo.checkStatus,
392
+ checkSummary: checkInfo.checkSummary,
393
+ autoMergeEnabled: info.autoMergeRequest !== null,
394
+ } satisfies PullRequestMergeInfo
395
+ })
396
+
397
+ const mergePullRequest = Effect.fn("GitHubService.mergePullRequest")(function*(repository: string, number: number, action: PullRequestMergeAction) {
398
+ const base = ["pr", "merge", String(number), "--repo", repository] as const
399
+ yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
400
+ })
401
+
265
402
  const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
266
403
  yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
267
404
  })
@@ -283,8 +420,11 @@ export class GitHubService extends Context.Service<GitHubService, {
283
420
 
284
421
  return GitHubService.of({
285
422
  listOpenPullRequests,
423
+ listOpenPullRequestDetails,
286
424
  getAuthenticatedUser,
287
425
  getPullRequestDiff,
426
+ getPullRequestMergeInfo,
427
+ mergePullRequest,
288
428
  toggleDraftStatus,
289
429
  listRepoLabels,
290
430
  addPullRequestLabel,