agent-orchestrator/frontend/src/renderer/lib/pr-display.ts

499 lines
14 KiB
TypeScript

import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
import { sortedPRs, type PRState, type PullRequestFacts, type WorkspaceSession } from "../types/workspace";
const prStateRank: Record<PRState, number> = { open: 0, draft: 1, merged: 2, closed: 3 };
const ciStates = new Set<SessionPRSummary["ci"]["state"]>(["unknown", "pending", "passing", "failing"]);
const reviewDecisions = new Set<SessionPRSummary["review"]["decision"]>([
"none",
"approved",
"changes_requested",
"review_required",
]);
const mergeabilityStates = new Set<SessionPRSummary["mergeability"]["state"]>([
"unknown",
"mergeable",
"conflicting",
"blocked",
"unstable",
]);
export type PRDisplayTone = "neutral" | "passive" | "success" | "warning" | "error";
export type PRStatusRow = {
key: "ci" | "review" | "merge";
label: string;
value: string;
detail?: string;
tone: PRDisplayTone;
};
export type PRSummaryPartKey = "ci" | "review" | "merge";
export type PRSummaryLink = {
label: string;
href?: string;
title?: string;
};
export type PRSummaryPart = {
key: PRSummaryPartKey;
label: string;
status: string;
summary?: string;
links: PRSummaryLink[];
overflowLabel?: string;
tone: PRDisplayTone;
};
export function comparePRDisplaySummaries(a: SessionPRSummary, b: SessionPRSummary): number {
return prStateRank[a.state] - prStateRank[b.state] || a.number - b.number;
}
export function prBrowserUrl(pr: SessionPRSummary): string {
return prBaseUrl(pr) ?? pr.htmlUrl ?? pr.url;
}
export function sessionPRDisplaySummaries(
session: WorkspaceSession,
summaries: SessionPRSummary[] = [],
): SessionPRSummary[] {
const summariesByNumber = new Map(summaries.map((summary) => [summary.number, summary]));
const seen = new Set<number>();
const fromFacts = sortedPRs(session).map((pr) => {
seen.add(pr.number);
return summariesByNumber.get(pr.number) ?? sessionPRFactToSummary(session, pr);
});
const summaryOnly = summaries.filter((summary) => !seen.has(summary.number));
return [...fromFacts, ...summaryOnly].sort(comparePRDisplaySummaries);
}
function sessionPRFactToSummary(session: WorkspaceSession, pr: PullRequestFacts): SessionPRSummary {
return {
url: pr.url,
htmlUrl: pr.url,
number: pr.number,
title: session.title,
state: pr.state,
provider: "github",
repo: session.workspaceName,
author: "",
sourceBranch: session.branch,
targetBranch: "",
headSha: "",
additions: 0,
deletions: 0,
changedFiles: 0,
ci: {
state: toCIState(pr.ci),
failingChecks: [],
},
review: {
decision: toReviewDecision(pr.review),
hasUnresolvedHumanComments: pr.reviewComments,
unresolvedBy: [],
},
mergeability: {
state: toMergeabilityState(pr.mergeability),
reasons: [],
prUrl: pr.url,
conflictFiles: [],
},
updatedAt: pr.updatedAt,
observedAt: pr.updatedAt,
ciObservedAt: pr.updatedAt,
reviewObservedAt: pr.updatedAt,
};
}
export function prStatusRows(pr: SessionPRSummary): PRStatusRow[] {
return prSummaryParts(pr).map((part) => ({
key: part.key,
label: part.label,
value: part.status,
detail: part.key === "merge" ? formatDiffSummary(pr) : undefined,
tone: part.tone,
}));
}
export function prSummaryParts(pr: SessionPRSummary): PRSummaryPart[] {
return [
{
key: "ci",
label: "CI",
status: ciLabel(pr.ci.state),
summary: ciSummary(pr),
links: ciLinks(pr),
overflowLabel: pr.ci.state === "failing" ? overflowLabel(pr.ci.failingChecks.length, 3, "check") : undefined,
tone: ciTone(pr.ci.state),
},
{
key: "merge",
label: "Merge",
status: mergeabilityLabel(pr.mergeability.state),
summary: mergeSummary(pr),
links: mergeLinks(pr),
overflowLabel: mergeOverflowLabel(pr),
tone: mergeabilityTone(pr.mergeability.state),
},
{
key: "review",
label: "Review",
status: reviewLabel(pr.review.decision),
summary: reviewSummary(pr),
links: reviewLinks(pr),
overflowLabel:
pr.state === "draft" || pr.review.decision === "review_required"
? undefined
: overflowLabel(pr.review.unresolvedBy.length, 3, "reviewer"),
tone: reviewTone(pr.review.decision, pr.review.hasUnresolvedHumanComments),
},
];
}
export function prDiffSummary(pr: SessionPRSummary): string | undefined {
const parts: string[] = [];
if (pr.changedFiles > 0) {
parts.push(`${pr.changedFiles} ${pluralize("file", pr.changedFiles)}`);
}
const lineDelta = formatLineDelta(pr.additions, pr.deletions);
if (lineDelta) {
parts.push(lineDelta);
}
return parts.length > 0 ? parts.join(" · ") : undefined;
}
function ciSummary(pr: SessionPRSummary): string | undefined {
if (pr.ci.state === "failing") {
return pr.ci.failingChecks.length === 0 ? "No failing check link observed" : "Fix failing CI";
}
return undefined;
}
function ciLinks(pr: SessionPRSummary): PRSummaryLink[] {
if (pr.ci.state !== "failing") {
return [];
}
return pr.ci.failingChecks.slice(0, 3).map((check) => ({
label: check.name,
href: check.url || undefined,
title: check.conclusion || check.status,
}));
}
function reviewSummary(pr: SessionPRSummary): string | undefined {
if (pr.state === "merged" || pr.state === "closed") {
return undefined;
}
if (pr.state === "draft") {
return "Draft PR · Not ready for review";
}
if (pr.review.decision === "changes_requested" || pr.review.hasUnresolvedHumanComments) {
return "Address requested changes";
}
if (pr.review.decision === "review_required") {
return "Required review not submitted";
}
return undefined;
}
function reviewLinks(pr: SessionPRSummary): PRSummaryLink[] {
if (pr.state === "merged" || pr.state === "closed" || pr.state === "draft") {
return [];
}
if (pr.review.decision !== "changes_requested" && !pr.review.hasUnresolvedHumanComments) {
return [];
}
const links = pr.review.unresolvedBy.slice(0, 3).map((reviewer) => reviewAttentionLink(pr, reviewer));
if (links.length === 0 && pr.review.decision === "changes_requested") {
links.push({ label: "PR", href: prBrowserUrl(pr), title: "Open pull request" });
}
return links;
}
function mergeSummary(pr: SessionPRSummary): string | undefined {
if (pr.state === "merged" || pr.state === "closed") {
return formatDiffSummary(pr);
}
if (pr.mergeability.state === "conflicting") {
return "Resolve merge conflict";
}
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
return "Merge blocked";
}
return formatDiffSummary(pr);
}
function mergeLinks(pr: SessionPRSummary): PRSummaryLink[] {
if (pr.state === "merged" || pr.state === "closed") {
return [];
}
if (pr.mergeability.state === "conflicting") {
return mergeAttentionLinks(pr, "merge_conflict");
}
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
return mergeAttentionLinks(pr, "merge_blocked");
}
return [];
}
function mergeOverflowLabel(pr: SessionPRSummary): string | undefined {
if (pr.state === "merged" || pr.state === "closed") {
return undefined;
}
const hasFileLinks = (pr.mergeability.conflictFiles ?? []).length > 0;
if (hasFileLinks) {
return overflowLabel(pr.mergeability.conflictFiles?.length ?? 0, 3, "file");
}
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
return overflowLabel(pr.mergeability.reasons.length, 3, "reason");
}
return undefined;
}
function toCIState(value: string): SessionPRSummary["ci"]["state"] {
return ciStates.has(value as SessionPRSummary["ci"]["state"])
? (value as SessionPRSummary["ci"]["state"])
: "unknown";
}
function toReviewDecision(value: string): SessionPRSummary["review"]["decision"] {
return reviewDecisions.has(value as SessionPRSummary["review"]["decision"])
? (value as SessionPRSummary["review"]["decision"])
: "none";
}
function toMergeabilityState(value: string): SessionPRSummary["mergeability"]["state"] {
return mergeabilityStates.has(value as SessionPRSummary["mergeability"]["state"])
? (value as SessionPRSummary["mergeability"]["state"])
: "unknown";
}
function ciLabel(state: SessionPRSummary["ci"]["state"]): string {
switch (state) {
case "passing":
return "Passing";
case "failing":
return "Failing";
case "pending":
return "Pending";
case "unknown":
return "Checking";
}
}
function ciTone(state: SessionPRSummary["ci"]["state"]): PRDisplayTone {
switch (state) {
case "passing":
return "success";
case "failing":
return "error";
case "pending":
return "neutral";
case "unknown":
return "passive";
}
}
function reviewLabel(decision: SessionPRSummary["review"]["decision"]): string {
switch (decision) {
case "approved":
return "Approved";
case "changes_requested":
return "Changes requested";
case "review_required":
return "Pending";
case "none":
return "None";
}
}
function reviewTone(
decision: SessionPRSummary["review"]["decision"],
hasUnresolvedHumanComments: boolean,
): PRDisplayTone {
switch (decision) {
case "approved":
return "success";
case "changes_requested":
return "warning";
case "review_required":
return "neutral";
case "none":
return hasUnresolvedHumanComments ? "warning" : "passive";
}
}
function mergeabilityLabel(state: SessionPRSummary["mergeability"]["state"]): string {
switch (state) {
case "mergeable":
return "Mergeable";
case "conflicting":
return "Conflict";
case "blocked":
return "Blocked";
case "unstable":
return "Unstable";
case "unknown":
return "Checking";
}
}
function mergeabilityTone(state: SessionPRSummary["mergeability"]["state"]): PRDisplayTone {
switch (state) {
case "mergeable":
return "success";
case "conflicting":
return "error";
case "blocked":
case "unstable":
return "warning";
case "unknown":
return "passive";
}
}
function formatDiffSummary(pr: SessionPRSummary): string | undefined {
if (pr.changedFiles > 0) {
return `${pr.changedFiles} ${pluralize("file", pr.changedFiles)}`;
}
const changedLines = pr.additions + pr.deletions;
if (changedLines > 0) {
return `${changedLines} ${pluralize("line", changedLines)}`;
}
return undefined;
}
function formatLineDelta(additions: number, deletions: number): string | undefined {
const parts: string[] = [];
if (additions > 0) {
parts.push(`+${additions}`);
}
if (deletions > 0) {
parts.push(`-${deletions}`);
}
return parts.length > 0 ? parts.join(" ") : undefined;
}
function mergeAttentionLinks(pr: SessionPRSummary, kind: "merge_conflict" | "merge_blocked"): PRSummaryLink[] {
const href =
kind === "merge_conflict" ? mergeConflictUrl(pr) : pr.mergeability.prUrl || pr.htmlUrl || pr.url || undefined;
const fileLinks = (pr.mergeability.conflictFiles ?? []).slice(0, 3).map((file) => ({
label: file.path,
href: file.url || href,
title: kind === "merge_conflict" ? "Open merge conflicts" : undefined,
}));
const reasonLinks =
fileLinks.length > 0 || kind === "merge_conflict"
? []
: pr.mergeability.reasons.slice(0, 3).map((reason) => ({
label: mergeReasonLabel(reason),
href,
}));
const fallbackLink =
kind === "merge_conflict" && href ? [{ label: "conflicts", href, title: "Open merge conflicts" }] : [];
return fileLinks.length > 0 ? fileLinks : reasonLinks.length > 0 ? reasonLinks : fallbackLink;
}
function mergeConflictUrl(pr: SessionPRSummary): string | undefined {
return prSubpageUrl(pr, "conflicts") ?? pr.mergeability.prUrl ?? prBrowserUrl(pr);
}
function prBaseUrl(pr: SessionPRSummary): string | undefined {
return prURL(pr);
}
function prSubpageUrl(pr: SessionPRSummary, subpage: "conflicts"): string | undefined {
const base = prURL(pr);
return base ? `${base}/${subpage}` : undefined;
}
function prURL(pr: SessionPRSummary): string | undefined {
const raw = pr.htmlUrl || pr.mergeability.prUrl || pr.url;
if (!raw) {
return undefined;
}
try {
const url = new URL(raw);
const match = url.pathname.match(/^(\/[^/]+\/[^/]+)\/(?:pull|issues)\/(\d+)(?:\/.*)?$/);
if (!match) {
return undefined;
}
url.pathname = `${match[1]}/pull/${match[2]}`;
url.search = "";
url.hash = "";
return url.toString();
} catch {
return undefined;
}
}
function reviewerLabel(reviewer: SessionPRSummary["review"]["unresolvedBy"][number]): string {
const name = reviewerDisplayName(reviewer);
if (reviewer.count <= 1) {
return name;
}
return `${name} +${reviewer.count - 1}`;
}
function reviewerDisplayName(reviewer: SessionPRSummary["review"]["unresolvedBy"][number]): string {
return reviewer.isBot ? `${reviewer.reviewerId} bot` : reviewer.reviewerId;
}
function reviewAttentionLink(
pr: SessionPRSummary,
reviewer: SessionPRSummary["review"]["unresolvedBy"][number],
): PRSummaryLink {
const inlineURL = reviewer.links.find((link) => link.url)?.url;
if (reviewer.reviewUrl) {
return {
label: reviewerLabel(reviewer),
href: reviewer.reviewUrl,
title: `Open requested-changes review from ${reviewerDisplayName(reviewer)}`,
};
}
if (inlineURL) {
return {
label: reviewerLabel(reviewer),
href: inlineURL,
title:
reviewer.count > 0
? `${reviewer.count} unresolved ${pluralize("comment", reviewer.count)} from ${reviewerDisplayName(reviewer)}`
: `Open review comments from ${reviewerDisplayName(reviewer)}`,
};
}
return {
label: reviewerLabel(reviewer),
href: prBrowserUrl(pr),
title: `Open pull request for ${reviewerDisplayName(reviewer)}`,
};
}
function mergeReasonLabel(reason: string): string {
switch (reason) {
case "behind_base":
return "branch behind base";
case "ci_failing":
return "CI failing";
case "changes_requested":
return "changes requested";
case "review_required":
return "review required";
case "blocked_by_provider":
return "provider blocked";
default:
return reason.replaceAll("_", " ");
}
}
function overflowLabel(total: number, shown: number, noun: string): string | undefined {
const extra = total - shown;
if (extra <= 0) {
return undefined;
}
return `+${extra} ${pluralize(noun, extra)}`;
}
function pluralize(noun: string, count: number): string {
return count === 1 ? noun : `${noun}s`;
}