import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; import { type Forge, getForgePresentation } from "@/git/forge"; import type { PresentableCheck } from "@/git/check-presentation"; import { parseClientForgeFacts } from "@/git/forges"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors"; import { type CheckStatus, mapCheckStatus } from "./check-status"; import { getNativeFallbackChecks } from "./native-data"; export type { CheckStatus } from "./check-status"; export type PrState = "open" | "draft" | "merged" | "closed"; export type ReviewState = "approved" | "changes_requested" | "commented"; export type ActivityKind = "review" | "comment"; export type PullRequestProvider = Forge; export interface PullRequestProviderMetadata { id: PullRequestProvider; label: string; url?: string | null; } export interface PrPaneCheck extends PresentableCheck { provider: PullRequestProvider; name: string; workflow?: string; status: CheckStatus; /** * What the row says about time: how long a finished check took ("2m"), or how long a * running one has been going ("running 2m"). Absent when the forge reports neither. */ timing?: string; isManual?: boolean; requiresAction?: boolean; url: string; /** * Forge-neutral reference for fetching this check's detail/logs on demand. Any * forge that exposes a check-run id populates it; the daemon resolves the logs * through the neutral check-details RPC. */ detailRef?: { checkRunId?: number; workflowRunId?: number; }; } export interface PrPaneActivity { provider: PullRequestProvider; id: string; kind: ActivityKind; author: string; authorUrl?: string | null; avatarColor: string; avatarUrl?: string | null; reviewState?: ReviewState; body: string; age: string; url: string; /** For inline review comments: the review this comment was submitted with. */ reviewId?: string; /** * Forge-neutral discussion id, independent of a file position. Groups general * (non-file) reply chains into one thread; file threads also carry it. */ threadId?: string; /** * Resolution state for a thread with no file position (e.g. a GitLab general * discussion). File threads carry resolution under `location.isResolved`. */ threadIsResolved?: boolean; location?: { path: string; line?: number; startLine?: number; threadId?: string; isResolved?: boolean; isOutdated?: boolean; }; } export interface PrPaneData { provider: PullRequestProviderMetadata; /** The forge hosting this change request. */ forge: Forge; number: number; repoOwner?: string; repoName?: string; /** Neutral project identity (GitLab namespaces nest beyond owner/name). */ projectPath?: string; title: string; state: PrState; url: string; reviewDecision: "approved" | "changes_requested" | "pending"; awaitingReviewers: string[]; checks: PrPaneCheck[]; /** * The forge's already-validated native facts, passed through so pane native * contributions (e.g. GitLab pipeline/approvals) derive their surfaces without * the neutral data type carrying forge-specific fields. */ forgeSpecific?: ForgeSpecificStatusFacts; activity: PrPaneActivity[]; } type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"]; type PullRequestTimeline = PullRequestTimelineResponse["payload"]; type PullRequestTimelineItem = PullRequestTimeline["items"][number]; export function mapPrPaneData( status: CheckoutPrStatus, timeline: PullRequestTimeline | null | undefined, nowMs = Date.now(), forge: Forge = "github", ): PrPaneData | null { if (!status) { return null; } const number = status.number ?? parsePullRequestNumber(status.url); if (number === null) { return null; } const timelineMatchesStatus = timeline?.prNumber === number; const provider = toProviderMetadata(forge); const forgeSpecific = parseClientForgeFacts(status.forgeSpecific); return { provider, forge, number, repoOwner: status.repoOwner, repoName: status.repoName, projectPath: status.projectPath, title: status.title, state: derivePrState(status), url: status.url, reviewDecision: mapReviewDecision(status.reviewDecision), // Requested reviewers are intentionally unwired until the server exposes them. awaitingReviewers: [], checks: mapChecks(status, forge), ...(forgeSpecific ? { forgeSpecific } : {}), activity: timelineMatchesStatus ? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge)) : [], }; } function toProviderMetadata(forge: Forge): PullRequestProviderMetadata { return { id: forge, label: getForgePresentation(forge).brandLabel }; } // Avatars are identity, not status: they draw from the shared identity table so a PR // participant square sits at the same weight as a project icon. Logins are matched // case-insensitively, so the same person keeps one color across forges. export function deriveAvatarColor(login: string): string { return identityColor(deriveIdentityColorName(login.toLowerCase())); } export function formatAge(createdAtMs: number, nowMs = Date.now()): string { const elapsedMs = Math.max(0, nowMs - createdAtMs); const elapsedSeconds = Math.floor(elapsedMs / 1000); if (elapsedSeconds < 60) { return "just now"; } const elapsedMinutes = Math.floor(elapsedSeconds / 60); if (elapsedMinutes < 60) { return `${elapsedMinutes}m ago`; } const elapsedHours = Math.floor(elapsedMinutes / 60); if (elapsedHours < 24) { return `${elapsedHours}h ago`; } const elapsedDays = Math.floor(elapsedHours / 24); if (elapsedDays < 30) { return `${elapsedDays}d ago`; } if (elapsedDays < 365) { return `${Math.floor(elapsedDays / 30)}mo ago`; } return `${Math.floor(elapsedDays / 365)}y ago`; } function derivePrState(status: NonNullable): PrState { if (status.isMerged || status.state === "merged") { return "merged"; } if (status.state !== "open") { return "closed"; } if (status.isDraft) { return "draft"; } return "open"; } function mapChecks(status: NonNullable, forge: Forge): PrPaneCheck[] { const checks = (status.checks ?? []).map((check) => mapCheck(check, forge, status.url)); if (checks.length > 0) { return checks; } return getNativeFallbackChecks(status, forge); } function mapCheck( check: NonNullable["checks"][number], forge: Forge, fallbackUrl: string, ): PrPaneCheck { const status = mapCheckStatus(check.status); const timing = formatCheckTiming(check.duration, status); return { provider: forge, name: check.name, status, ...(check.traits ? { traits: check.traits } : {}), url: check.url ?? fallbackUrl, ...(check.workflow ? { workflow: check.workflow } : {}), ...(timing ? { timing } : {}), ...(check.checkRunId !== undefined || check.workflowRunId !== undefined ? { detailRef: { ...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}), ...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}), }, } : {}), }; } /** * The forge measures how long a check ran, whether or not it has finished. Which one it * is comes from the status: on a running check a bare "2m" would read as "took 2m", so * the row says the run is still going. */ function formatCheckTiming(duration: string | undefined, status: CheckStatus): string | null { if (!duration) { return null; } return status === "pending" ? `running ${duration}` : duration; } function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] { if (item.kind === "comment") { if (item.body.trim() === "") { return []; } return [ { id: item.id, provider: forge, kind: "comment", author: item.author, authorUrl: item.authorUrl, avatarColor: deriveAvatarColor(item.author), avatarUrl: item.avatarUrl, body: item.body, age: formatAge(item.createdAt, nowMs), url: item.url, reviewId: item.reviewId, threadId: item.threadId, threadIsResolved: item.threadIsResolved, location: item.location, }, ]; } if (item.reviewState === "commented" && item.body.trim() === "") { return []; } return [ { id: item.id, provider: forge, kind: "review", author: item.author, authorUrl: item.authorUrl, avatarColor: deriveAvatarColor(item.author), avatarUrl: item.avatarUrl, reviewState: item.reviewState, body: item.body, age: formatAge(item.createdAt, nowMs), url: item.url, }, ]; } function mapReviewDecision( reviewDecision: NonNullable["reviewDecision"], ): PrPaneData["reviewDecision"] { if (reviewDecision === "approved" || reviewDecision === "changes_requested") { return reviewDecision; } return "pending"; } function parsePullRequestNumber(url: string): number | null { try { const match = new URL(url).pathname.match(/\/pull\/(\d+)(?:\/|$)/); if (!match) { return null; } const number = Number.parseInt(match[1], 10); return Number.isFinite(number) ? number : null; } catch { return null; } } export function getStateLabel(state: PrState): string { if (state === "draft") return "Draft"; if (state === "merged") return "Merged"; if (state === "closed") return "Closed"; return "Open"; } export function getActivityVerb(item: Pick): string { if (item.kind === "comment") return "Commented"; if (item.reviewState === "approved") return "Approved"; if (item.reviewState === "changes_requested") return "Requested changes"; return "Reviewed"; }