diff --git a/apps/web/src/components/pullRequest/pullRequestDependencyNavigation.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDependencyNavigation.logic.test.ts new file mode 100644 index 000000000000..0daafacf8473 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestDependencyNavigation.logic.test.ts @@ -0,0 +1,213 @@ +import type { PullRequestDependencyContext } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { pullRequestDependencyNavigation } from "./pullRequestDependencyNavigation.logic"; + +function context( + overrides: Partial = {}, +): PullRequestDependencyContext { + return { + focus: { projectId: "project" as never, repository: "acme/web", number: 42 }, + provider: "github", + host: "github.com", + repository: "acme/web", + coverage: "complete", + issues: [], + nodes: [41, 42, 43, 44].map((number) => ({ + ref: { projectId: "project" as never, repository: "acme/web", number }, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + state: "open" as const, + isDraft: false, + baseBranch: number === 41 ? "main" : `branch-${number - 1}`, + head: { repository: "acme/web", branch: `branch-${number}` }, + })), + edges: [], + ...overrides, + }; +} +const view = (value: PullRequestDependencyContext | null, pending = false) => + pullRequestDependencyNavigation({ supported: true, context: value, pending, failed: false }); + +describe("pull request dependency navigation", () => { + it("builds confirmed linear navigation around the focused request", () => { + const navigation = view( + context({ + edges: [ + { parent: 41, child: 42, certainty: "confirmed" }, + { parent: 42, child: 43, certainty: "confirmed" }, + ], + }), + ); + expect(navigation).toMatchObject({ + status: "ready", + path: [{ number: 41 }, { number: 42 }, { number: 43 }], + focusIndex: 1, + parent: 41, + child: 43, + rootBase: "main", + }); + }); + it("keeps siblings and branch points as choices", () => { + const navigation = view( + context({ + edges: [ + { parent: 41, child: 42, certainty: "confirmed" }, + { parent: 41, child: 44, certainty: "confirmed" }, + ], + }), + ); + expect(navigation).toMatchObject({ status: "ready", child: null, siblings: [{ number: 44 }] }); + }); + it("does not turn a candidate parent into a root base or navigation", () => { + const navigation = view( + context({ coverage: "partial", edges: [{ parent: 41, child: 42, certainty: "candidate" }] }), + ); + expect(navigation).toMatchObject({ + status: "ready", + rootBase: null, + parent: null, + possibleParents: [{ number: 41 }], + }); + }); + it("keeps native membership navigable when a member was not loaded", () => { + const navigation = view( + context({ + nodes: [], + native: { status: "present", id: "stack", members: [40, 42], coverage: "partial" }, + }), + ); + expect(navigation).toMatchObject({ + status: "ready", + native: { + status: "present", + members: [ + { number: 40, title: null }, + { number: 42, title: null }, + ], + }, + }); + }); + it("shows partial and unavailable empty results instead of claiming no dependencies", () => { + expect( + view( + context({ + coverage: "partial", + nodes: [42].map((number) => context().nodes.find((node) => node.ref.number === number)!), + }), + ), + ).toMatchObject({ status: "partial-empty" }); + expect(view(context({ coverage: "unavailable", nodes: [] }))).toMatchObject({ + status: "unavailable", + }); + }); + it("does not invent a root base below a candidate or cycle boundary", () => { + const candidateRoot = view( + context({ + focus: { projectId: "project" as never, repository: "acme/web", number: 43 }, + edges: [ + { parent: 41, child: 42, certainty: "confirmed" }, + { parent: 42, child: 43, certainty: "confirmed" }, + { parent: 44, child: 41, certainty: "candidate" }, + ], + }), + ); + expect(candidateRoot).toMatchObject({ status: "ready", rootBase: null }); + expect( + view( + context({ + issues: [{ reason: "cycle" }], + edges: [{ parent: 41, child: 42, certainty: "confirmed" }], + }), + ), + ).toMatchObject({ status: "ready", cycleAfter: true }); + }); + it("keeps native membership visible when branch relationship coverage is unavailable", () => { + const navigation = view( + context({ + coverage: "unavailable", + nodes: [], + native: { status: "present", id: "stack", members: [40, 42], coverage: "complete" }, + }), + ); + expect(navigation).toMatchObject({ + status: "ready", + coverage: "unavailable", + rootBase: null, + native: { status: "present" }, + }); + }); + it("turns multiple confirmed parents into an explicit choice", () => { + const navigation = view( + context({ + edges: [ + { parent: 41, child: 42, certainty: "confirmed" }, + { parent: 44, child: 42, certainty: "confirmed" }, + ], + }), + ); + expect(navigation).toMatchObject({ + status: "ready", + parent: null, + rootBase: null, + possibleParents: [{ number: 41 }, { number: 44 }], + }); + }); + it("withholds a root base when the host only flags an ambiguous parent", () => { + expect(view(context({ issues: [{ reason: "ambiguous-parent" }] }))).toMatchObject({ + status: "ready", + parent: null, + parentAmbiguous: true, + rootBase: null, + possibleParents: [], + }); + }); + it("withholds a root base when an ancestor has multiple confirmed parents", () => { + const navigation = view( + context({ + edges: [ + { parent: 41, child: 42, certainty: "confirmed" }, + { parent: 40, child: 41, certainty: "confirmed" }, + { parent: 44, child: 41, certainty: "confirmed" }, + ], + }), + ); + expect(navigation).toMatchObject({ + status: "ready", + path: [{ number: 41 }, { number: 42 }], + rootBase: null, + }); + }); + it("keeps the nearest child inside a bounded long chain", () => { + const seed = context(); + const nodes = Array.from({ length: 27 }, (_, index) => { + const number = index + 1; + return { + ...seed.nodes[0]!, + ref: { projectId: "project" as never, repository: "acme/web", number }, + title: `PR ${number}`, + baseBranch: number === 1 ? "main" : `branch-${number - 1}`, + }; + }); + const edges = Array.from({ length: 26 }, (_, index) => ({ + parent: index + 1, + child: index + 2, + certainty: "confirmed" as const, + })); + const navigation = view( + context({ + focus: { projectId: "project" as never, repository: "acme/web", number: 26 }, + nodes, + edges, + }), + ); + expect(navigation).toMatchObject({ + status: "ready", + truncatedBefore: true, + truncatedAfter: false, + }); + if (navigation.status !== "ready") throw new Error("expected ready navigation"); + expect(navigation.path).toHaveLength(20); + expect(navigation.path.at(-1)?.number).toBe(27); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDependencyNavigation.logic.ts b/apps/web/src/components/pullRequest/pullRequestDependencyNavigation.logic.ts new file mode 100644 index 000000000000..f9623c6e8a2c --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestDependencyNavigation.logic.ts @@ -0,0 +1,217 @@ +import type { PullRequestDependencyContext, PullRequestState } from "@t3tools/contracts"; + +export interface DependencyChip { + readonly number: number; + readonly title: string | null; + readonly state: PullRequestState | null; + readonly isDraft: boolean; + readonly baseBranch: string | null; +} + +export type DependencyNavigation = + | { readonly status: "hidden" } + | { readonly status: "pending" } + | { readonly status: "unavailable" } + | { readonly status: "partial-empty" } + | { + readonly status: "ready"; + readonly path: ReadonlyArray; + readonly focusIndex: number; + readonly rootBase: string | null; + readonly truncatedBefore: boolean; + readonly truncatedAfter: boolean; + readonly cycleBefore: boolean; + readonly cycleAfter: boolean; + readonly parent: number | null; + readonly parentAmbiguous: boolean; + readonly child: number | null; + readonly children: ReadonlyArray; + readonly siblings: ReadonlyArray; + readonly possibleParents: ReadonlyArray; + readonly possibleChildren: ReadonlyArray; + readonly coverage: "complete" | "partial" | "unavailable"; + readonly native: + | { readonly status: "hidden" | "unavailable" } + | { + readonly status: "present"; + readonly members: ReadonlyArray; + readonly coverage: "complete" | "partial"; + }; + }; + +const MAX_PATH_NODES = 20; + +function chipFromNode( + node: PullRequestDependencyContext["nodes"][number] | undefined, + number: number, +): DependencyChip { + return node + ? { + number, + title: node.title, + state: node.state, + isDraft: node.isDraft, + baseBranch: node.baseBranch, + } + : { number, title: null, state: null, isDraft: false, baseBranch: null }; +} + +function unique(numbers: ReadonlyArray): number | null { + return numbers.length === 1 ? (numbers[0] ?? null) : null; +} + +/** Derives bounded, confirmed navigation. Candidate edges only appear as explicit choices. */ +export function pullRequestDependencyNavigation(input: { + readonly supported: boolean; + readonly context: PullRequestDependencyContext | null; + readonly pending: boolean; + readonly failed: boolean; +}): DependencyNavigation { + if (!input.supported) return { status: "hidden" }; + if (input.context === null) + return input.pending ? { status: "pending" } : { status: "unavailable" }; + const context = input.context; + const byNumber = new Map(context.nodes.map((node) => [node.ref.number, node])); + const focus = context.focus.number; + const incoming = new Map(); + const outgoing = new Map(); + const candidateIncoming = new Map(); + const candidateOutgoing = new Map(); + for (const edge of context.edges) { + const target = edge.certainty === "confirmed" ? incoming : candidateIncoming; + target.set(edge.child, [...(target.get(edge.child) ?? []), edge.parent]); + const reverse = edge.certainty === "confirmed" ? outgoing : candidateOutgoing; + reverse.set(edge.parent, [...(reverse.get(edge.parent) ?? []), edge.child]); + } + const toChips = (numbers: ReadonlyArray) => + numbers.map((number) => chipFromNode(byNumber.get(number), number)); + const confirmedParents = incoming.get(focus) ?? []; + const parent = unique(confirmedParents); + const child = unique(outgoing.get(focus) ?? []); + const parentAmbiguous = context.issues.some((issue) => issue.reason === "ambiguous-parent"); + const possibleParents = toChips([ + ...new Set([ + ...(confirmedParents.length > 1 ? confirmedParents : []), + ...(candidateIncoming.get(focus) ?? []), + ]), + ]); + const possibleChildren = toChips(candidateOutgoing.get(focus) ?? []); + const ancestors: DependencyChip[] = []; + const descendants: DependencyChip[] = []; + let truncatedBefore = false; + let truncatedAfter = false; + let cycleBefore = false; + let cycleAfter = false; + const beforeSeen = new Set([focus]); + let before = parent; + while (before !== null) { + if (beforeSeen.has(before)) { + cycleBefore = true; + break; + } + if (ancestors.length >= MAX_PATH_NODES - 1) { + truncatedBefore = true; + break; + } + beforeSeen.add(before); + ancestors.unshift(chipFromNode(byNumber.get(before), before)); + before = unique(incoming.get(before) ?? []); + } + const afterSeen = new Set(beforeSeen); + let after = child; + while (after !== null) { + if (afterSeen.has(after)) { + cycleAfter = true; + break; + } + if (descendants.length >= MAX_PATH_NODES - 1) { + truncatedAfter = true; + break; + } + afterSeen.add(after); + descendants.push(chipFromNode(byNumber.get(after), after)); + after = unique(outgoing.get(after) ?? []); + } + const budget = MAX_PATH_NODES - 1; + const keepBefore = Math.min( + ancestors.length, + Math.max(Math.floor(budget / 2), budget - descendants.length), + ); + const keepAfter = Math.min(descendants.length, budget - keepBefore); + truncatedBefore ||= keepBefore < ancestors.length; + truncatedAfter ||= keepAfter < descendants.length; + const keptAncestors = ancestors.slice(ancestors.length - keepBefore); + const keptDescendants = descendants.slice(0, keepAfter); + const path = [...keptAncestors, chipFromNode(byNumber.get(focus), focus), ...keptDescendants]; + const pathEnd = path.at(-1)?.number ?? focus; + const unresolvedRoot = + truncatedBefore || + cycleBefore || + (candidateIncoming.get(path[0]?.number ?? focus)?.length ?? 0) > 0 || + (incoming.get(path[0]?.number ?? focus)?.length ?? 0) > 1 || + context.coverage === "unavailable"; + const endChildren = outgoing.get(pathEnd) ?? []; + const children = endChildren.length > 1 ? toChips(endChildren) : []; + const siblings = + parent === null ? [] : toChips((outgoing.get(parent) ?? []).filter((n) => n !== focus)); + const native = + context.native?.status === "present" + ? { + status: "present" as const, + members: toChips(context.native.members), + coverage: context.native.coverage, + } + : context.native?.status === "unavailable" + ? { status: "unavailable" as const } + : { status: "hidden" as const }; + // The transport intentionally records only that a cycle was found, not an invented direction. + // When traversal did not encounter it itself, place the stop after the known path. + if (context.issues.some((issue) => issue.reason === "cycle") && !cycleBefore && !cycleAfter) { + cycleAfter = true; + } + const hasGraph = + keptAncestors.length > 0 || + keptDescendants.length > 0 || + children.length > 0 || + siblings.length > 0 || + possibleParents.length > 0 || + possibleChildren.length > 0 || + truncatedBefore || + truncatedAfter || + cycleBefore || + cycleAfter || + parentAmbiguous; + const hasNative = native.status !== "hidden"; + const unavailable = + context.coverage === "unavailable" || + context.issues.some((issue) => issue.reason === "host-unavailable"); + if (!hasGraph && !hasNative) { + if (unavailable || input.failed) return { status: "unavailable" }; + return context.coverage === "partial" || + context.issues.some((issue) => issue.reason === "budget") + ? { status: "partial-empty" } + : { status: "hidden" }; + } + return { + status: "ready", + path, + focusIndex: keptAncestors.length, + rootBase: + possibleParents.length > 0 || parentAmbiguous || unresolvedRoot + ? null + : (path[0]?.baseBranch ?? null), + truncatedBefore, + truncatedAfter, + cycleBefore, + cycleAfter, + parent, + parentAmbiguous, + child, + children, + siblings, + possibleParents, + possibleChildren, + coverage: context.coverage, + native, + }; +}