import * as Schema from "effect/Schema";import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { PullRequestDetail, type PullRequestAction, type PullRequestActor, type PullRequestBaseComparison, type PullRequestCheck, type PullRequestChecksState, type PullRequestComment, type PullRequestCommit, type PullRequestDetailView, type PullRequestMergeability, type PullRequestMergeMethod, type PullRequestReaction, type PullRequestReviewThread, type PullRequestState, type PullRequestUpdateMethod, type SourceControlProviderKind, type VcsRef,} from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = { merge: "Merge", squash: "Squash and merge", rebase: "Rebase and merge",}; /** Old environments keep their existing actions; new ones must finish stack discovery first. */export function allowsSinglePullRequestMerge(input: { supportsStackActions: boolean; hasStack: boolean; stackPending: boolean; stackError: string | null;}): boolean { return ( !input.supportsStackActions || (!input.hasStack && !input.stackPending && input.stackError === null) );} export function resolvePullRequestMergeMethod( allowed: ReadonlyArray<PullRequestMergeMethod>, current: PullRequestMergeMethod | null, projectDefault: PullRequestMergeMethod | undefined, lastSelected: PullRequestMergeMethod,): PullRequestMergeMethod { for (const method of [current, projectDefault, lastSelected]) { if (method && allowed.includes(method)) return method; } return allowed[0] ?? "merge";} const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; export type PullRequestPrimaryControl = | "resolve" | "ready" | "merge" | "enable-auto-merge" | "auto-merge-armed" | "merged" | "closed" | null; /** The one merge-area state shown in the header, including terminal and deferred states. */export function resolvePullRequestPrimaryControl(input: { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; readonly checksState: PullRequestChecksState | null; readonly autoMergeEnabled: boolean | undefined; readonly hasMergeMethod: boolean; readonly canMerge: boolean; readonly canMarkReady: boolean; readonly canEnableAutoMerge: boolean;}): PullRequestPrimaryControl { if (input.state === "merged") return "merged"; if (input.state === "closed") return "closed"; if (input.mergeability === "conflicting") return "resolve"; if (input.isDraft) return input.canMarkReady ? "ready" : null; if (input.autoMergeEnabled) return "auto-merge-armed"; if (!input.hasMergeMethod) return null; if ( input.autoMergeEnabled === false && input.checksState !== null && input.checksState !== "passing" && input.canEnableAutoMerge ) { return "enable-auto-merge"; } return input.canMerge ? "merge" : null;} export function pullRequestCheckoutCommand( provider: SourceControlProviderKind, number: number, headBranch: string, headRepositoryNameWithOwner?: string | null,): string | null { switch (provider) { case "github": return `gh pr checkout ${number}`; case "gitlab": return `glab mr checkout ${number}`; case "azure-devops": return `az repos pr checkout --id ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || !bitbucketRepositoryName.test(headRepositoryNameWithOwner) || !safeShellArgument.test(headBranch) ) { return null; } return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`; } case "unknown": return null; }} /** Activity changes only when the same host resource reports a newer revision. */export function shouldRefreshPullRequestActivity( previous: { readonly key: string; readonly updatedAt: string } | null, next: { readonly key: string; readonly updatedAt: string },): boolean { return previous !== null && previous.key === next.key && previous.updatedAt !== next.updatedAt;}/** Appends fetched pages without replacing fresher comments already in the activity response. */export function mergePullRequestThreadComments<T extends { readonly id: string }>( base: ReadonlyArray<T>, loaded: ReadonlyArray<T>,): ReadonlyArray<T> { const seen = new Set(base.map((comment) => comment.id)); return [ ...base, ...loaded.filter((comment) => { if (seen.has(comment.id)) return false; seen.add(comment.id); return true; }), ];} export function editPullRequestThreadComment< T extends { readonly id: string; readonly body: string },>(comments: ReadonlyArray<T>, commentId: string, body: string): ReadonlyArray<T> { return comments.map((comment) => (comment.id === commentId ? { ...comment, body } : comment));} /** * Whether the pull request on a right-panel surface is the thread's own one. Repository and * number are not enough: one environment can hold two checkouts of the same repository under * different projects, and the other project's checkout is somebody else's branch. */export function isThreadOwnPullRequest( thread: { readonly projectId: string | null; readonly repository: string | null; readonly number: number | null; }, surface: { readonly projectId: string; readonly repository: string; readonly number: number; },): boolean { return ( thread.projectId === surface.projectId && thread.repository === surface.repository && thread.number === surface.number );} /** Names where a pull-request task will land, without letting each surface guess independently. */export function pullRequestHandoffLabels(inThisThread: boolean) { return inThisThread ? { fixFinding: "Fix in this thread", fixCheck: "Fix in this thread", fixFindings: "Fix findings in this thread", } : { fixFinding: "Fix in a thread", fixCheck: "Fix", fixFindings: "Fix findings in a thread", };} export function pullRequestComposerTarget<T>( context: "page" | "thread", target: T | null | undefined,): T | null { return context === "thread" ? (target ?? null) : null;} /** Whether the open pull-request action group contains at least one action. */export function pullRequestActionMenuHasGroup( showsDraftToggle: boolean, showsAutoMerge: boolean, showsMergeMethods: boolean,): boolean { return showsDraftToggle || showsAutoMerge || showsMergeMethods;} export function isStackedPullRequestBase( baseBranch: string, refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault" | "isRemote" | "remoteName">>,): boolean { const defaultRef = refs.find((refName) => refName.isDefault); if (!defaultRef) return false; if (defaultRef.isRemote !== true) return defaultRef.name !== baseBranch; const remotePrefix = `${defaultRef.remoteName ?? defaultRef.name.split("/")[0]}/`; const defaultBranch = defaultRef.name.startsWith(remotePrefix) ? defaultRef.name.slice(remotePrefix.length) : defaultRef.name; return defaultBranch !== baseBranch;} /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */export function orderPullRequestComments<T extends { readonly createdAt: string }>( comments: ReadonlyArray<T>, order: "newest" | "oldest",): ReadonlyArray<T> { return order === "newest" ? comments.toReversed() : comments;} /** A review that says something about the change itself, rather than only carrying remarks. */export type PullRequestReviewOutcome = "approved" | "changes-requested" | "dismissed"; /** * Which review states are a verdict. Hosts spell the same three differently — GitHub reports * `CHANGES_REQUESTED`, Bitbucket `changes_requested` — so case and separator are ignored, and * anything else (GitHub's `COMMENTED`, a state no host here reports yet) is not a verdict. */export function pullRequestReviewOutcome( reviewState: string | null,): PullRequestReviewOutcome | null { switch (reviewState?.trim().toLowerCase().replaceAll("_", "-")) { case "approved": return "approved"; case "changes-requested": return "changes-requested"; case "dismissed": return "dismissed"; default: return null; }} /** * An instant as a number, because the text is not the order. Every host returns ISO-8601 but not * all of them in UTC, and `2026-07-05T01:00:00+02:00` sorts after `2026-07-05T00:30:00Z` as text * while falling an hour and a half before it in time. NaN for anything unparseable, which every * caller treats as "cannot say" rather than as a position. */function instant(iso: string): number { return Date.parse(iso);} /** * The newest commit on the branch, which is what a verdict is current against. Null where the * host reported no commits — or none with a timestamp that parses — since nothing can then be * said to predate them. */export function newestPullRequestCommitAt( commits: ReadonlyArray<PullRequestCommit>,): string | null { let newest: string | null = null; let newestAt = Number.NEGATIVE_INFINITY; for (const commit of commits) { const at = instant(commit.committedDate); if (Number.isNaN(at) || at <= newestAt) continue; newest = commit.committedDate; newestAt = at; } return newest;} /** * Whether a verdict was given before the code it was given on. * * Measured against commit dates, which is the only thing the detail carries. That is a proxy and * not the question: a commit date says when the work was written, not when it reached this change * request, so pushing a branch of older commits after an approval leaves the approval reading as * current, and a rebase re-dates commits a verdict already covered. Answering it exactly needs * the host's own review-to-commit link — GitHub hangs a commit off every review — which no * adapter reads yet. Until one does, this errs towards leaving a verdict alone: it dims only * where the branch plainly moved on. */export function isPullRequestVerdictStale(at: string, newestCommitAt: string | null): boolean { if (newestCommitAt === null) return false; const verdictAt = instant(at); const commitAt = instant(newestCommitAt); return !Number.isNaN(verdictAt) && !Number.isNaN(commitAt) && verdictAt < commitAt;} export interface PullRequestReviewOutcomeEntry { /** * What made this entry its own reviewer. A login where the host reported one, and otherwise the * review's own id — so a surface listing these has a key that separates the same two authorless * verdicts this does, rather than collapsing them back into one row. */ readonly key: string; readonly actor: PullRequestActor | null; readonly outcome: PullRequestReviewOutcome; readonly at: string; /** Commits landed after this verdict, so it speaks for code that is no longer on the branch. */ readonly stale: boolean;} /** * Where each reviewer landed, which is what "is this approved?" actually asks. One entry per * person and only their last word: a host keeps every review somebody ever submitted, and an * approval later followed by a request for changes is not an approval any more. A dismissal is a * verdict taken back, so it leaves nothing to show rather than showing itself. */export function latestPullRequestReviewOutcomes( comments: ReadonlyArray<PullRequestComment>, /** Left empty by a caller with no commits to hand, which makes no verdict stale. */ commits: ReadonlyArray<PullRequestCommit> = [],): ReadonlyArray<PullRequestReviewOutcomeEntry> { const newestCommitAt = newestPullRequestCommitAt(commits); const latest = new Map<string, PullRequestReviewOutcomeEntry>(); for (const comment of comments) { const outcome = pullRequestReviewOutcome(comment.reviewState); if (outcome === null) continue; // Two deleted accounts are two reviewers. Keying both as "ghost" would let one overwrite the // other and undercount the verdicts, so a review with no author identity stands alone. const login = comment.author?.login ?? `ghost:${comment.id}`; const current = latest.get(login); // Not every host returns its reviews in order, so the newest wins rather than the last read. if (current !== undefined && instant(current.at) > instant(comment.createdAt)) continue; latest.set(login, { key: login, actor: comment.author, outcome, at: comment.createdAt, stale: isPullRequestVerdictStale(comment.createdAt, newestCommitAt), }); } return [...latest.values()].filter((entry) => entry.outcome !== "dismissed");} export interface PullRequestTimelineEvent { readonly id: string; readonly at: string; readonly kind: "opened" | "commit" | "comment" | "review" | "merged" | "closed"; readonly title: string; readonly body: string | null; /** Whether `body` is markdown. A commit headline is plain text and must not be parsed as one. */ readonly markdown: boolean; /** Where the entry can be read on the host. Null for events the host gives no page of its own. */ readonly url: string | null; readonly actor: PullRequestActor | null; /** Every author attributed by the host, with the first used for the timeline marker. */ readonly commitAuthors: ReadonlyArray<PullRequestActor>; readonly additions: number | null; readonly deletions: number | null; readonly path: string | null; readonly reviewState: string | null; /** Empty for everything but a comment, which is the only entry a host lets anyone react to. */ readonly reactions: ReadonlyArray<PullRequestReaction>;} export type PullRequestTimelineRow = | { readonly kind: "event"; readonly event: PullRequestTimelineEvent } | { readonly kind: "comments"; readonly events: ReadonlyArray<PullRequestTimelineEvent> }; /** * Consecutive comments are one conversation section. Commits and pull-request lifecycle updates * stay first-class rows and split those sections, so expanding a conversation never hides the * work that happened between two review rounds. * * A verdict is a first-class row too. Whether the change was approved is the question a reader * opens the timeline with, and folding the answer into a collapsed "9 comments" section hides it * behind a press — the one thing on the page that must be readable without one. */export function groupPullRequestTimelineConversations( events: ReadonlyArray<PullRequestTimelineEvent>,): ReadonlyArray<PullRequestTimelineRow> { const rows: PullRequestTimelineRow[] = []; for (const event of events) { if ( (event.kind === "comment" || event.kind === "review") && pullRequestReviewOutcome(event.reviewState) === null ) { const last = rows.at(-1); if (last?.kind === "comments") { rows[rows.length - 1] = { kind: "comments", events: [...last.events, event] }; } else { rows.push({ kind: "comments", events: [event] }); } } else { rows.push({ kind: "event", event }); } } return rows;} /** * Review bots keep their bookkeeping in HTML comments, which the markdown renderer drops. A body * that is nothing but a marker therefore renders as an empty block, so it is treated as no body * at all. The stripped text decides that and nothing else: the body itself is passed on whole, * because a comment demonstrating an HTML comment inside a code fence still has to show it. */export function visibleBody(body: string): string | null { return body.replace(/<!--[\s\S]*?-->/gu, "").trim().length === 0 ? null : body.trim();} /** * Flattens creation, commits, comments/reviews, and the terminal event into one list, newest * first. What happened last is what a reader opening the tab is asking about — whether it merged, * what the last review said — and the history reads backwards from there rather than making them * scroll to the bottom to find the present. * * Merged wins over closed: GitHub sets both timestamps on a merge, and reporting "closed" for a * merged pull request would misstate what happened. */export function buildPullRequestTimeline( detail: Pick< PullRequestDetailView, "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" >,): ReadonlyArray<PullRequestTimelineEvent> { return [ { id: "created", at: detail.createdAt, kind: "opened" as const, title: "opened this pull request", body: null, markdown: false, url: null, actor: detail.author, commitAuthors: [], additions: null, deletions: null, path: null, reviewState: null, reactions: [], }, ...detail.commits.map((commit) => ({ id: commit.oid, at: commit.committedDate, kind: "commit" as const, title: `Commit ${commit.oid.slice(0, 7)}`, body: commit.messageHeadline || null, markdown: false, url: null, actor: commit.authors?.[0] ?? null, commitAuthors: commit.authors ?? [], additions: commit.additions ?? null, deletions: commit.deletions ?? null, path: null, reviewState: null, reactions: [], })), ...detail.comments.map((comment) => ({ id: comment.id, at: comment.createdAt, kind: comment.kind === "review" ? ("review" as const) : ("comment" as const), title: comment.kind === "review" ? "reviewed" : "commented", body: visibleBody(comment.body), markdown: true, url: comment.url, actor: comment.author, commitAuthors: [], additions: null, deletions: null, path: comment.path, reviewState: comment.reviewState, reactions: comment.reactions ?? [], })), ...(detail.mergedAt ? [ { id: "merged", at: detail.mergedAt, kind: "merged" as const, title: "Pull request merged", body: null, markdown: false, url: null, actor: null, commitAuthors: [], additions: null, deletions: null, path: null, reviewState: null, reactions: [], }, ] : []), ...(detail.closedAt && !detail.mergedAtLines 1–500 of 1119. Load more
import * as Schema from "effect/Schema"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { PullRequestDetail, type PullRequestAction, type PullRequestActor, type PullRequestBaseComparison, type PullRequestCheck, type PullRequestChecksState, type PullRequestComment, type PullRequestCommit, type PullRequestDetailView, type PullRequestMergeability, type PullRequestMergeMethod, type PullRequestReaction, type PullRequestReviewThread, type PullRequestState, type PullRequestUpdateMethod, type SourceControlProviderKind, type VcsRef, } from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = { merge: "Merge", squash: "Squash and merge", rebase: "Rebase and merge", }; /** Old environments keep their existing actions; new ones must finish stack discovery first. */ export function allowsSinglePullRequestMerge(input: { supportsStackActions: boolean; hasStack: boolean; stackPending: boolean; stackError: string | null; }): boolean { return ( !input.supportsStackActions || (!input.hasStack && !input.stackPending && input.stackError === null) ); } export function resolvePullRequestMergeMethod( allowed: ReadonlyArray<PullRequestMergeMethod>, current: PullRequestMergeMethod | null, projectDefault: PullRequestMergeMethod | undefined, lastSelected: PullRequestMergeMethod, ): PullRequestMergeMethod { for (const method of [current, projectDefault, lastSelected]) { if (method && allowed.includes(method)) return method; } return allowed[0] ?? "merge"; } const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; export type PullRequestPrimaryControl = | "resolve" | "ready" | "merge" | "enable-auto-merge" | "auto-merge-armed" | "merged" | "closed" | null; /** The one merge-area state shown in the header, including terminal and deferred states. */ export function resolvePullRequestPrimaryControl(input: { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; readonly checksState: PullRequestChecksState | null; readonly autoMergeEnabled: boolean | undefined; readonly hasMergeMethod: boolean; readonly canMerge: boolean; readonly canMarkReady: boolean; readonly canEnableAutoMerge: boolean; }): PullRequestPrimaryControl { if (input.state === "merged") return "merged"; if (input.state === "closed") return "closed"; if (input.mergeability === "conflicting") return "resolve"; if (input.isDraft) return input.canMarkReady ? "ready" : null; if (input.autoMergeEnabled) return "auto-merge-armed"; if (!input.hasMergeMethod) return null; if ( input.autoMergeEnabled === false && input.checksState !== null && input.checksState !== "passing" && input.canEnableAutoMerge ) { return "enable-auto-merge"; } return input.canMerge ? "merge" : null; } export function pullRequestCheckoutCommand( provider: SourceControlProviderKind, number: number, headBranch: string, headRepositoryNameWithOwner?: string | null, ): string | null { switch (provider) { case "github": return `gh pr checkout ${number}`; case "gitlab": return `glab mr checkout ${number}`; case "azure-devops": return `az repos pr checkout --id ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || !bitbucketRepositoryName.test(headRepositoryNameWithOwner) || !safeShellArgument.test(headBranch) ) { return null; } return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`; } case "unknown": return null; } } /** Activity changes only when the same host resource reports a newer revision. */ export function shouldRefreshPullRequestActivity( previous: { readonly key: string; readonly updatedAt: string } | null, next: { readonly key: string; readonly updatedAt: string }, ): boolean { return previous !== null && previous.key === next.key && previous.updatedAt !== next.updatedAt; } /** Appends fetched pages without replacing fresher comments already in the activity response. */ export function mergePullRequestThreadComments<T extends { readonly id: string }>( base: ReadonlyArray<T>, loaded: ReadonlyArray<T>, ): ReadonlyArray<T> { const seen = new Set(base.map((comment) => comment.id)); return [ ...base, ...loaded.filter((comment) => { if (seen.has(comment.id)) return false; seen.add(comment.id); return true; }), ]; } export function editPullRequestThreadComment< T extends { readonly id: string; readonly body: string }, >(comments: ReadonlyArray<T>, commentId: string, body: string): ReadonlyArray<T> { return comments.map((comment) => (comment.id === commentId ? { ...comment, body } : comment)); } /** * Whether the pull request on a right-panel surface is the thread's own one. Repository and * number are not enough: one environment can hold two checkouts of the same repository under * different projects, and the other project's checkout is somebody else's branch. */ export function isThreadOwnPullRequest( thread: { readonly projectId: string | null; readonly repository: string | null; readonly number: number | null; }, surface: { readonly projectId: string; readonly repository: string; readonly number: number; }, ): boolean { return ( thread.projectId === surface.projectId && thread.repository === surface.repository && thread.number === surface.number ); } /** Names where a pull-request task will land, without letting each surface guess independently. */ export function pullRequestHandoffLabels(inThisThread: boolean) { return inThisThread ? { fixFinding: "Fix in this thread", fixCheck: "Fix in this thread", fixFindings: "Fix findings in this thread", } : { fixFinding: "Fix in a thread", fixCheck: "Fix", fixFindings: "Fix findings in a thread", }; } export function pullRequestComposerTarget<T>( context: "page" | "thread", target: T | null | undefined, ): T | null { return context === "thread" ? (target ?? null) : null; } /** Whether the open pull-request action group contains at least one action. */ export function pullRequestActionMenuHasGroup( showsDraftToggle: boolean, showsAutoMerge: boolean, showsMergeMethods: boolean, ): boolean { return showsDraftToggle || showsAutoMerge || showsMergeMethods; } export function isStackedPullRequestBase( baseBranch: string, refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault" | "isRemote" | "remoteName">>, ): boolean { const defaultRef = refs.find((refName) => refName.isDefault); if (!defaultRef) return false; if (defaultRef.isRemote !== true) return defaultRef.name !== baseBranch; const remotePrefix = `${defaultRef.remoteName ?? defaultRef.name.split("/")[0]}/`; const defaultBranch = defaultRef.name.startsWith(remotePrefix) ? defaultRef.name.slice(remotePrefix.length) : defaultRef.name; return defaultBranch !== baseBranch; } /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */ export function orderPullRequestComments<T extends { readonly createdAt: string }>( comments: ReadonlyArray<T>, order: "newest" | "oldest", ): ReadonlyArray<T> { return order === "newest" ? comments.toReversed() : comments; } /** A review that says something about the change itself, rather than only carrying remarks. */ export type PullRequestReviewOutcome = "approved" | "changes-requested" | "dismissed"; /** * Which review states are a verdict. Hosts spell the same three differently — GitHub reports * `CHANGES_REQUESTED`, Bitbucket `changes_requested` — so case and separator are ignored, and * anything else (GitHub's `COMMENTED`, a state no host here reports yet) is not a verdict. */ export function pullRequestReviewOutcome( reviewState: string | null, ): PullRequestReviewOutcome | null { switch (reviewState?.trim().toLowerCase().replaceAll("_", "-")) { case "approved": return "approved"; case "changes-requested": return "changes-requested"; case "dismissed": return "dismissed"; default: return null; } } /** * An instant as a number, because the text is not the order. Every host returns ISO-8601 but not * all of them in UTC, and `2026-07-05T01:00:00+02:00` sorts after `2026-07-05T00:30:00Z` as text * while falling an hour and a half before it in time. NaN for anything unparseable, which every * caller treats as "cannot say" rather than as a position. */ function instant(iso: string): number { return Date.parse(iso); } /** * The newest commit on the branch, which is what a verdict is current against. Null where the * host reported no commits — or none with a timestamp that parses — since nothing can then be * said to predate them. */ export function newestPullRequestCommitAt( commits: ReadonlyArray<PullRequestCommit>, ): string | null { let newest: string | null = null; let newestAt = Number.NEGATIVE_INFINITY; for (const commit of commits) { const at = instant(commit.committedDate); if (Number.isNaN(at) || at <= newestAt) continue; newest = commit.committedDate; newestAt = at; } return newest; } /** * Whether a verdict was given before the code it was given on. * * Measured against commit dates, which is the only thing the detail carries. That is a proxy and * not the question: a commit date says when the work was written, not when it reached this change * request, so pushing a branch of older commits after an approval leaves the approval reading as * current, and a rebase re-dates commits a verdict already covered. Answering it exactly needs * the host's own review-to-commit link — GitHub hangs a commit off every review — which no * adapter reads yet. Until one does, this errs towards leaving a verdict alone: it dims only * where the branch plainly moved on. */ export function isPullRequestVerdictStale(at: string, newestCommitAt: string | null): boolean { if (newestCommitAt === null) return false; const verdictAt = instant(at); const commitAt = instant(newestCommitAt); return !Number.isNaN(verdictAt) && !Number.isNaN(commitAt) && verdictAt < commitAt; } export interface PullRequestReviewOutcomeEntry { /** * What made this entry its own reviewer. A login where the host reported one, and otherwise the * review's own id — so a surface listing these has a key that separates the same two authorless * verdicts this does, rather than collapsing them back into one row. */ readonly key: string; readonly actor: PullRequestActor | null; readonly outcome: PullRequestReviewOutcome; readonly at: string; /** Commits landed after this verdict, so it speaks for code that is no longer on the branch. */ readonly stale: boolean; } /** * Where each reviewer landed, which is what "is this approved?" actually asks. One entry per * person and only their last word: a host keeps every review somebody ever submitted, and an * approval later followed by a request for changes is not an approval any more. A dismissal is a * verdict taken back, so it leaves nothing to show rather than showing itself. */ export function latestPullRequestReviewOutcomes( comments: ReadonlyArray<PullRequestComment>, /** Left empty by a caller with no commits to hand, which makes no verdict stale. */ commits: ReadonlyArray<PullRequestCommit> = [], ): ReadonlyArray<PullRequestReviewOutcomeEntry> { const newestCommitAt = newestPullRequestCommitAt(commits); const latest = new Map<string, PullRequestReviewOutcomeEntry>(); for (const comment of comments) { const outcome = pullRequestReviewOutcome(comment.reviewState); if (outcome === null) continue; // Two deleted accounts are two reviewers. Keying both as "ghost" would let one overwrite the // other and undercount the verdicts, so a review with no author identity stands alone. const login = comment.author?.login ?? `ghost:${comment.id}`; const current = latest.get(login); // Not every host returns its reviews in order, so the newest wins rather than the last read. if (current !== undefined && instant(current.at) > instant(comment.createdAt)) continue; latest.set(login, { key: login, actor: comment.author, outcome, at: comment.createdAt, stale: isPullRequestVerdictStale(comment.createdAt, newestCommitAt), }); } return [...latest.values()].filter((entry) => entry.outcome !== "dismissed"); } export interface PullRequestTimelineEvent { readonly id: string; readonly at: string; readonly kind: "opened" | "commit" | "comment" | "review" | "merged" | "closed"; readonly title: string; readonly body: string | null; /** Whether `body` is markdown. A commit headline is plain text and must not be parsed as one. */ readonly markdown: boolean; /** Where the entry can be read on the host. Null for events the host gives no page of its own. */ readonly url: string | null; readonly actor: PullRequestActor | null; /** Every author attributed by the host, with the first used for the timeline marker. */ readonly commitAuthors: ReadonlyArray<PullRequestActor>; readonly additions: number | null; readonly deletions: number | null; readonly path: string | null; readonly reviewState: string | null; /** Empty for everything but a comment, which is the only entry a host lets anyone react to. */ readonly reactions: ReadonlyArray<PullRequestReaction>; } export type PullRequestTimelineRow = | { readonly kind: "event"; readonly event: PullRequestTimelineEvent } | { readonly kind: "comments"; readonly events: ReadonlyArray<PullRequestTimelineEvent> }; /** * Consecutive comments are one conversation section. Commits and pull-request lifecycle updates * stay first-class rows and split those sections, so expanding a conversation never hides the * work that happened between two review rounds. * * A verdict is a first-class row too. Whether the change was approved is the question a reader * opens the timeline with, and folding the answer into a collapsed "9 comments" section hides it * behind a press — the one thing on the page that must be readable without one. */ export function groupPullRequestTimelineConversations( events: ReadonlyArray<PullRequestTimelineEvent>, ): ReadonlyArray<PullRequestTimelineRow> { const rows: PullRequestTimelineRow[] = []; for (const event of events) { if ( (event.kind === "comment" || event.kind === "review") && pullRequestReviewOutcome(event.reviewState) === null ) { const last = rows.at(-1); if (last?.kind === "comments") { rows[rows.length - 1] = { kind: "comments", events: [...last.events, event] }; } else { rows.push({ kind: "comments", events: [event] }); } } else { rows.push({ kind: "event", event }); } } return rows; } /** * Review bots keep their bookkeeping in HTML comments, which the markdown renderer drops. A body * that is nothing but a marker therefore renders as an empty block, so it is treated as no body * at all. The stripped text decides that and nothing else: the body itself is passed on whole, * because a comment demonstrating an HTML comment inside a code fence still has to show it. */ export function visibleBody(body: string): string | null { return body.replace(/<!--[\s\S]*?-->/gu, "").trim().length === 0 ? null : body.trim(); } /** * Flattens creation, commits, comments/reviews, and the terminal event into one list, newest * first. What happened last is what a reader opening the tab is asking about — whether it merged, * what the last review said — and the history reads backwards from there rather than making them * scroll to the bottom to find the present. * * Merged wins over closed: GitHub sets both timestamps on a merge, and reporting "closed" for a * merged pull request would misstate what happened. */ export function buildPullRequestTimeline( detail: Pick< PullRequestDetailView, "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" >, ): ReadonlyArray<PullRequestTimelineEvent> { return [ { id: "created", at: detail.createdAt, kind: "opened" as const, title: "opened this pull request", body: null, markdown: false, url: null, actor: detail.author, commitAuthors: [], additions: null, deletions: null, path: null, reviewState: null, reactions: [], }, ...detail.commits.map((commit) => ({ id: commit.oid, at: commit.committedDate, kind: "commit" as const, title: `Commit ${commit.oid.slice(0, 7)}`, body: commit.messageHeadline || null, markdown: false, url: null, actor: commit.authors?.[0] ?? null, commitAuthors: commit.authors ?? [], additions: commit.additions ?? null, deletions: commit.deletions ?? null, path: null, reviewState: null, reactions: [], })), ...detail.comments.map((comment) => ({ id: comment.id, at: comment.createdAt, kind: comment.kind === "review" ? ("review" as const) : ("comment" as const), title: comment.kind === "review" ? "reviewed" : "commented", body: visibleBody(comment.body), markdown: true, url: comment.url, actor: comment.author, commitAuthors: [], additions: null, deletions: null, path: comment.path, reviewState: comment.reviewState, reactions: comment.reactions ?? [], })), ...(detail.mergedAt ? [ { id: "merged", at: detail.mergedAt, kind: "merged" as const, title: "Pull request merged", body: null, markdown: false, url: null, actor: null, commitAuthors: [], additions: null, deletions: null, path: null, reviewState: null, reactions: [], }, ] : []), ...(detail.closedAt && !detail.mergedAt