1211 lines · 47.4 KB · TypeScript
RawDownload
import * as Schema from "effect/Schema"; import {  PullRequestDetail,  type PullRequestAction,  type PullRequestActor,  type PullRequestBaseComparison,  type PullRequestCheck,  type PullRequestChecksState as ContractPullRequestChecksState,  type PullRequestComment,  type PullRequestCommit,  type PullRequestDetailView,  type PullRequestMergeability,  type PullRequestReaction,  type PullRequestMergeMethod,  type PullRequestReviewThread,  type PullRequestState,  type PullRequestUpdateMethod,  type SourceControlProviderKind,  type VcsRef,} from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; 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: ContractPullRequestChecksState | 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;} /** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {  if (state === "merged") return "Merged";  if (state === "closed") return "Closed";  return isDraft ? "Draft" : "Ready for review";} /** The slice of a detail that decides which actions it offers. */export type PullRequestActionableDetail = Pick<  PullRequestDetail,  "state" | "isDraft" | "mergeability" | "capabilities" | "viewerPermissions" | "mergeCapabilities">; /** * The host says which strategies it offers at all; the repository narrows that to the ones it * actually allows. */export function allowedPullRequestMergeMethods(  detail: Pick<PullRequestActionableDetail, "capabilities" | "mergeCapabilities"> | null,): ReadonlyArray<PullRequestMergeMethod> {  return detail === null    ? []    : detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]);} /** The reader's preference where the repository allows it, and the first allowed method else. */export function resolveSelectedMergeMethod(  allowedMergeMethods: ReadonlyArray<PullRequestMergeMethod>,  preferred: PullRequestMergeMethod,): PullRequestMergeMethod {  return allowedMergeMethods.includes(preferred) ? preferred : (allowedMergeMethods[0] ?? "merge");} /** * Two questions, both of which have to say yes: whether this host can do it at all, and whether * this account may. A reader with read access on someone else's project sees the pull request and * none of the buttons that would only ever be refused. */export function canPerformPullRequestAction(  detail: Pick<PullRequestActionableDetail, "capabilities" | "viewerPermissions"> | null,  action: PullRequestAction,): boolean {  return (    detail !== null &&    detail.capabilities.actions.includes(action) &&    detail.viewerPermissions.actions.includes(action)  );} export function isPullRequestConflicting(  detail: Pick<PullRequestActionableDetail, "state" | "mergeability"> | null,): boolean {  return detail?.state === "open" && detail.mergeability === "conflicting";} /** * One live action holds the slot. A conflicting change cannot be merged now, so the slot goes to * the thing that would help instead of a Merge button that only ever says no. */export function resolvePullRequestPrimaryAction(  detail: PullRequestActionableDetail | null,): "ready" | "merge" | "resolve" | null {  if (detail === null || detail.state !== "open") return null;  if (detail.isDraft && canPerformPullRequestAction(detail, "ready")) return "ready";  if (!canPerformPullRequestAction(detail, "merge")) return null;  if (isPullRequestConflicting(detail)) return "resolve";  return allowedPullRequestMergeMethods(detail).length > 0 ? "merge" : null;} /** The checks as one word. Failing outranks running: a red run is already worth acting on. */export type PullRequestChecksState = "none" | "pending" | "failing" | "passing"; export function classifyPullRequestChecks(  checks: ReadonlyArray<PullRequestCheck>,): PullRequestChecksState {  if (checks.length === 0) return "none";  if (checks.some((check) => check.status === "failure" || check.status === "cancelled")) {    return "failing";  }  if (checks.some((check) => check.status === "pending")) return "pending";  return "passing";} /** * The checks with every live facet, unlike the single-facet summary: a run with failures in it * still says how much is in flight — "7 of 16 running · 1 failed" — because both numbers change * what a reader does next. */export function describePullRequestChecks(checks: ReadonlyArray<PullRequestCheck>): string {  if (checks.length === 0) return "No checks reported";  const failed = checks.filter(    (check) => check.status === "failure" || check.status === "cancelled",  ).length;  const pending = checks.filter((check) => check.status === "pending").length;  const passed = checks.filter((check) => check.status === "success").length;  const parts: string[] = [];  if (pending > 0) parts.push(`${pending} of ${checks.length} running`);  if (failed > 0) {    parts.push(pending > 0 ? `${failed} failed` : `${failed} of ${checks.length} failing`);  }  if (parts.length === 0) {    return passed === checks.length ? "All checks passed" : `${passed} of ${checks.length} passing`;  }  return parts.join(" · ");} export type ThreadPanelPullRequestAction = "resolve" | "ready" | "fix" | "merge"; /** * Which single action the thread panel's compact pull request row offers, ranked by what * unblocks the merge next: conflicts stop everything, a draft is not up for review yet, failing * checks want fixing, and only a clean open pull request earns Merge. While checks run the slot * stays empty — the row shows their progress instead of an action that would race them. */export function resolveThreadPanelPullRequestAction(  detail: (PullRequestActionableDetail & Pick<PullRequestDetail, "checks">) | null,): ThreadPanelPullRequestAction | null {  if (detail === null || detail.state !== "open") return null;  if (isPullRequestConflicting(detail)) return "resolve";  if (detail.isDraft) {    return canPerformPullRequestAction(detail, "ready") ? "ready" : null;  }  const checks = classifyPullRequestChecks(detail.checks);  if (checks === "failing") return "fix";  if (checks === "pending") return null;  return canPerformPullRequestAction(detail, "merge") &&    allowedPullRequestMergeMethods(detail).length > 0    ? "merge"    : null;} /** 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 });    }

Lines 1–500 of 1211.