import { Context, Deferred, Effect, Layer, Result, Schema } from "effect"
import { FileSystemService } from "./FileSystemService"
const CACHE_TTL_MS = 30_000
const COMMAND_TIMEOUT_MS = 15_000
type PullRequestChecks = "passing" | "failing" | "pending" | "none"
type PullRequestReview =
| "approved"
| "changes_requested"
| "review_required"
| "none"
type PullRequestMergeMethod = "merge" | "squash" | "rebase"
/** Live review state for one GitHub pull request, read through `gh`. */
export type LivePullRequest =
| {
readonly state: "available"
readonly url: string
readonly prState: "open" | "closed" | "merged"
readonly draft: boolean
readonly review: PullRequestReview
readonly checks: PullRequestChecks
readonly mergeable: "mergeable" | "conflicting" | "unknown"
/** GitHub's mergeStateStatus, such as CLEAN, BLOCKED, or BEHIND. */
readonly mergeState: string | null
readonly headSha: string | null
}
| {
readonly state: "unavailable"
readonly url: string
readonly reason: string
}
const CheckRun = Schema.Struct({
__typename: Schema.Literal("CheckRun"),
status: Schema.optional(Schema.NullOr(Schema.String)),
conclusion: Schema.optional(Schema.NullOr(Schema.String)),
})
const StatusContext = Schema.Struct({
__typename: Schema.Literal("StatusContext"),
state: Schema.optional(Schema.NullOr(Schema.String)),
})
const GitHubPullRequestStatus = Schema.Struct({
state: Schema.Literals(["OPEN", "CLOSED", "MERGED"]),
isDraft: Schema.Boolean,
reviewDecision: Schema.optional(Schema.NullOr(Schema.String)),
latestReviews: Schema.optional(
Schema.NullOr(
Schema.Array(
Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)) }),
),
),
),
statusCheckRollup: Schema.optional(
Schema.NullOr(
Schema.Array(Schema.Union([CheckRun, StatusContext, Schema.Struct({})])),
),
),
mergeable: Schema.optional(Schema.NullOr(Schema.String)),
mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)),
headRefOid: Schema.optional(Schema.NullOr(Schema.String)),
})
type GitHubPullRequestStatus = Schema.Schema.Type<
typeof GitHubPullRequestStatus
>
const GitHubRepositoryMergeMethod = Schema.Struct({
viewerDefaultMergeMethod: Schema.optional(Schema.NullOr(Schema.String)),
})
const PASSING_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"])
const FAILING_CONCLUSIONS = new Set([
"FAILURE",
"TIMED_OUT",
"CANCELLED",
"ACTION_REQUIRED",
"STARTUP_FAILURE",
"STALE",
])
const summarizeChecks = (
rollup: GitHubPullRequestStatus["statusCheckRollup"],
): PullRequestChecks => {
if (!rollup?.length) return "none"
let pending = false
for (const check of rollup) {
if ("__typename" in check && check.__typename === "CheckRun") {
const conclusion = check.conclusion?.toUpperCase() ?? null
if (conclusion && FAILING_CONCLUSIONS.has(conclusion)) return "failing"
if (
check.status?.toUpperCase() !== "COMPLETED" ||
!conclusion ||
!PASSING_CONCLUSIONS.has(conclusion)
)
pending = true
} else if ("__typename" in check && check.__typename === "StatusContext") {
const state = check.state?.toUpperCase() ?? null
if (state === "FAILURE" || state === "ERROR") return "failing"
if (state !== "SUCCESS") pending = true
} else {
pending = true
}
}
return pending ? "pending" : "passing"
}
const summarizeReview = (
status: GitHubPullRequestStatus,
): PullRequestReview => {
switch (status.reviewDecision?.toUpperCase()) {
case "APPROVED":
return "approved"
case "CHANGES_REQUESTED":
return "changes_requested"
case "REVIEW_REQUIRED":
return "review_required"
}
// Repositories without required reviews report no decision; fall back to
// the latest review from each reviewer.
const states = (status.latestReviews ?? []).map((review) =>
review.state?.toUpperCase(),
)
if (states.includes("CHANGES_REQUESTED")) return "changes_requested"
if (states.includes("APPROVED")) return "approved"
return "none"
}
const parseLivePullRequest = (url: string, stdout: string): LivePullRequest => {
let json: unknown
try {
json = JSON.parse(stdout)
} catch {
return { state: "unavailable", url, reason: "gh returned invalid JSON" }
}
const decoded = Schema.decodeUnknownResult(GitHubPullRequestStatus)(json)
if (Result.isFailure(decoded)) {
return {
state: "unavailable",
url,
reason: `gh returned an unexpected response: ${decoded.failure.message}`,
}
}
const status = decoded.success
const mergeable = status.mergeable?.toUpperCase()
return {
state: "available",
url,
prState: status.state.toLowerCase() as "open" | "closed" | "merged",
draft: status.isDraft,
review: summarizeReview(status),
checks: summarizeChecks(status.statusCheckRollup),
mergeable:
mergeable === "MERGEABLE"
? "mergeable"
: mergeable === "CONFLICTING"
? "conflicting"
: "unknown",
mergeState: status.mergeStateStatus?.toUpperCase() || null,
headSha: status.headRefOid || null,
}
}
interface CacheEntry {
readonly expiresAt: number
readonly value: Deferred.Deferred
}
/** Share one in-flight lookup per key and keep results briefly. */
const cached = (
cache: Map>,
key: string,
lookup: Effect.Effect,
) =>
Effect.clockWith((clock) =>
Effect.suspend(() => {
const now = clock.currentTimeMillisUnsafe()
const existing = cache.get(key)
if (existing && existing.expiresAt > now)
return Deferred.await(existing.value)
const entry = {
expiresAt: now + CACHE_TTL_MS,
value: Deferred.makeUnsafe(),
}
cache.set(key, entry)
return Deferred.into(lookup, entry.value).pipe(
Effect.onInterrupt(() =>
Effect.sync(() => {
if (cache.get(key) === entry) cache.delete(key)
}),
),
Effect.andThen(Deferred.await(entry.value)),
)
}),
)
const runGh = (argv: readonly string[]) =>
Effect.gen(function* () {
const fs = yield* FileSystemService
return yield* fs
.runCommand(argv, { captureOutput: true, timeoutMs: COMMAND_TIMEOUT_MS })
.pipe(
Effect.map((result) =>
result.exitCode === 0
? ({ ok: true, stdout: result.stdout } as const)
: ({
ok: false,
reason:
result.stderr.trim() ||
result.stdout.trim() ||
`'${argv.join(" ")}' exited with code ${result.exitCode}`,
} as const),
),
Effect.catch((error) =>
Effect.succeed({
ok: false,
reason: `Could not run 'gh': ${error.message}`,
} as const),
),
)
})
export class PullRequestStatusService extends Context.Service()(
"PullRequestStatusService",
{
make: Effect.sync(() => {
const statuses = new Map>()
const mergeMethods = new Map>()
return {
/**
* Read live GitHub PR review state; failures become `unavailable`.
* `fresh` bypasses the short-lived cache.
*/
read: (url: string, options?: { readonly fresh?: boolean }) =>
Effect.suspend(() => {
if (options?.fresh) statuses.delete(url)
return cached(
statuses,
url,
runGh([
"gh",
"pr",
"view",
url,
"--json",
"state,isDraft,reviewDecision,latestReviews,statusCheckRollup,mergeable,mergeStateStatus,headRefOid",
]).pipe(
Effect.map(
(result): LivePullRequest =>
result.ok
? parseLivePullRequest(url, result.stdout)
: { state: "unavailable", url, reason: result.reason },
),
),
)
}),
/** The viewer's default merge method for a repository, defaulting to merge. */
mergeMethod: (repository: string) =>
cached(
mergeMethods,
repository.toLowerCase(),
runGh([
"gh",
"repo",
"view",
repository,
"--json",
"viewerDefaultMergeMethod",
]).pipe(
Effect.map((result): PullRequestMergeMethod => {
if (!result.ok) return "merge"
try {
const decoded = Schema.decodeUnknownOption(
GitHubRepositoryMergeMethod,
)(JSON.parse(result.stdout))
const method =
decoded._tag === "Some"
? decoded.value.viewerDefaultMergeMethod?.toLowerCase()
: undefined
return method === "squash" || method === "rebase"
? method
: "merge"
} catch {
return "merge"
}
}),
),
),
}
}),
},
) {
static readonly layer = Layer.effect(this, this.make)
}