mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
164 lines
7.0 KiB
TypeScript
164 lines
7.0 KiB
TypeScript
import type {
|
||
GitHubPullRequestChecksConclusion,
|
||
GitHubPullRequestChecksRollup,
|
||
GitHubPullRequestMergeable,
|
||
GitHubPullRequestMergeStateStatus,
|
||
} from "../types";
|
||
|
||
// The PR sidebar row surfaces TWO independent facts, each tri-state and each
|
||
// sourced from the GitHub API snapshot:
|
||
//
|
||
// 1. CI status — derived from `checks_rollup` (primary) + counts.
|
||
// 2. Mergeability — derived from `mergeable` + `merge_state_status`.
|
||
//
|
||
// The two are intentionally decoupled: a PR can have failing checks AND a merge
|
||
// conflict, and both must show. Neither element is derived from the other, and
|
||
// neither is shown for terminal PRs (merged / closed) — the row's leading state
|
||
// icon already conveys terminal state; the caller applies that gate.
|
||
//
|
||
// Every input field is optional because older backends omit the snapshot
|
||
// fields; each rule defaults defensively (`?? 0`, `?? []`, explicit `=== "..."`
|
||
// checks) so an absent field never fabricates a positive verdict.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CI status
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Discriminated union for the CI element. `none` is a current snapshot with no
|
||
// checks; `unavailable` means there is no current snapshot region to render.
|
||
export type PullRequestChecksStatus =
|
||
| { kind: "failed"; failed: number; total: number; names: string[] }
|
||
| { kind: "pending"; passed: number; total: number; running: number }
|
||
| { kind: "passed"; total: number }
|
||
| { kind: "none" }
|
||
| { kind: "unavailable" };
|
||
|
||
export interface PullRequestChecksInput {
|
||
snapshot_available?: boolean;
|
||
checks_rollup?: GitHubPullRequestChecksRollup | null;
|
||
checks_conclusion?: GitHubPullRequestChecksConclusion | null;
|
||
checks_total?: number;
|
||
checks_passed?: number;
|
||
checks_failed?: number;
|
||
checks_running?: number;
|
||
checks_pending?: number;
|
||
failed_check_names?: string[];
|
||
}
|
||
|
||
// Priority (high → low):
|
||
// 0. explicitly unavailable snapshot → unavailable
|
||
// 1. rollup failure/error OR any failed count → failed
|
||
// 2. rollup pending/expected → pending
|
||
// 3. rollup success → passed
|
||
// 4. legacy provider conclusion → matching coarse state
|
||
// 5. current snapshot + null rollup → none ("no checks yet")
|
||
// 6. otherwise → unavailable
|
||
//
|
||
// Failure trusts the count as well as the rollup so a known legacy failure is
|
||
// surfaced even if its coarse conclusion lags. An explicit false availability
|
||
// gate always wins, so disabled or wrong-head GitHub data never leaks through.
|
||
export function deriveChecksStatus(input: PullRequestChecksInput): PullRequestChecksStatus {
|
||
if (input.snapshot_available === false) {
|
||
return { kind: "unavailable" };
|
||
}
|
||
const rollup = input.checks_rollup ?? null;
|
||
const total = input.checks_total ?? 0;
|
||
const passed = input.checks_passed ?? 0;
|
||
const failed = input.checks_failed ?? 0;
|
||
const running = input.checks_running ?? input.checks_pending ?? 0;
|
||
const names = input.failed_check_names ?? [];
|
||
|
||
if (rollup === "failure" || rollup === "error" || failed > 0) {
|
||
return { kind: "failed", failed, total, names };
|
||
}
|
||
if (rollup === "pending" || rollup === "expected") {
|
||
return { kind: "pending", passed, total, running };
|
||
}
|
||
if (rollup === "success") {
|
||
return { kind: "passed", total };
|
||
}
|
||
// Forgejo / Gitea / GitLab and older GitHub backends expose the coarse
|
||
// webhook-derived conclusion rather than a GraphQL rollup. Preserve those
|
||
// known passed/pending/failed states without confusing an absent API
|
||
// snapshot with a current "no checks" verdict.
|
||
if (input.checks_conclusion === "failed") {
|
||
return { kind: "failed", failed, total, names };
|
||
}
|
||
if (input.checks_conclusion === "pending") {
|
||
return { kind: "pending", passed, total, running };
|
||
}
|
||
if (input.checks_conclusion === "passed") {
|
||
return { kind: "passed", total };
|
||
}
|
||
return input.snapshot_available === true ? { kind: "none" } : { kind: "unavailable" };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mergeability
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Discriminated union for the mergeability element. `none` renders nothing:
|
||
// when GitHub has not decided (mergeable unknown/null and no decisive
|
||
// merge_state_status) the card asserts neither "conflict" nor "ready".
|
||
export type PullRequestMergeStatus =
|
||
| { kind: "conflicting" }
|
||
| { kind: "ready" }
|
||
| { kind: "blocked" }
|
||
| { kind: "behind" }
|
||
| { kind: "unstable" }
|
||
| { kind: "has_hooks" }
|
||
| { kind: "none" };
|
||
|
||
export interface PullRequestMergeInput {
|
||
snapshot_available?: boolean;
|
||
mergeable?: GitHubPullRequestMergeable | null;
|
||
merge_state_status?: GitHubPullRequestMergeStateStatus | null;
|
||
}
|
||
|
||
// Priority (high → low):
|
||
// 1. mergeable conflicting OR merge_state dirty → conflicting
|
||
// 2. merge_state clean → ready
|
||
// 3. merge_state blocked/behind/unstable/hooks → that faithful label
|
||
// 4. otherwise → none (render nothing)
|
||
//
|
||
// `mergeable` answers only "is there a conflict"; `merge_state_status === dirty`
|
||
// is GitHub's other view of the same fact (an unmergeable conflict), so both
|
||
// map to `conflicting`. "Ready" is asserted ONLY from `clean` — never inferred
|
||
// from `mergeable === "mergeable"`, which does not account for required checks
|
||
// or branch protection.
|
||
export function deriveMergeStatus(input: PullRequestMergeInput): PullRequestMergeStatus {
|
||
if (input.snapshot_available === false) return { kind: "none" };
|
||
const mergeable = input.mergeable ?? null;
|
||
const mergeState = input.merge_state_status ?? null;
|
||
|
||
if (mergeable === "conflicting" || mergeState === "dirty") return { kind: "conflicting" };
|
||
if (mergeState === "clean") return { kind: "ready" };
|
||
if (mergeState === "blocked") return { kind: "blocked" };
|
||
if (mergeState === "behind") return { kind: "behind" };
|
||
if (mergeState === "unstable") return { kind: "unstable" };
|
||
if (mergeState === "has_hooks") return { kind: "has_hooks" };
|
||
return { kind: "none" };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Diff stats
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PullRequestStatsInput {
|
||
additions?: number;
|
||
deletions?: number;
|
||
changed_files?: number;
|
||
}
|
||
|
||
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
|
||
// when the backend that served this PR row doesn't know about the stats
|
||
// columns yet, every numeric field defaults to 0. Rendering "+0 −0 · 0 files"
|
||
// in that case would be a lie (the PR almost certainly has real changes),
|
||
// so we hide the entire stats row until at least one signal is non-zero.
|
||
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
|
||
const a = input.additions ?? 0;
|
||
const d = input.deletions ?? 0;
|
||
const f = input.changed_files ?? 0;
|
||
return a + d + f > 0;
|
||
}
|