From 8f7be0dee79ecdd3cd5582c4311def1b2af4b423 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:30:48 -0500 Subject: [PATCH] feat(pull-requests): model verified branch dependencies --- .../pullRequestDependencyTopology.test.ts | 269 +++++++++++++++ .../pullRequestDependencyTopology.ts | 310 ++++++++++++++++++ packages/contracts/src/pullRequest.test.ts | 41 +++ packages/contracts/src/pullRequest.ts | 89 +++++ 4 files changed, 709 insertions(+) create mode 100644 apps/server/src/pullRequest/pullRequestDependencyTopology.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestDependencyTopology.ts diff --git a/apps/server/src/pullRequest/pullRequestDependencyTopology.test.ts b/apps/server/src/pullRequest/pullRequestDependencyTopology.test.ts new file mode 100644 index 000000000000..c2de7ce5a684 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestDependencyTopology.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + PullRequestDependencyContext, + type ProjectId, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import type { ProviderChangeRequest } from "./PullRequestProvider.ts"; +import { buildPullRequestDependencyContext } from "./pullRequestDependencyTopology.ts"; + +const projectId = "project-1" as ProjectId; + +function row( + number: number, + headBranch: string, + baseBranch: string, + headRepositoryNameWithOwner: string | null | undefined = "acme/web", +): ProviderChangeRequest { + return { + number, + title: `PR ${number}`, + url: `https://forge.test/acme/web/pulls/${number}`, + author: null, + headBranch, + ...(headRepositoryNameWithOwner === undefined ? {} : { headRepositoryNameWithOwner }), + baseBranch, + state: "open", + isDraft: false, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + reviewRequestLogins: [], + labels: [], + }; +} + +function context( + rows: ReadonlyArray, + focus = 2, + complete = true, + provider: SourceControlProviderKind = "github", +) { + return buildPullRequestDependencyContext({ + projectId, + provider, + host: provider === "gitea" ? "gitea.example.test" : "github.com", + repository: "acme/web", + focus, + rows, + complete, + }); +} + +describe("pull request dependency topology", () => { + it("builds a qualified GitHub chain and retains sibling choices", () => { + const result = context([ + row(1, "migration", "main"), + row(2, "api", "migration"), + row(3, "ui", "api"), + row(4, "docs", "migration"), + ]); + + expect(result.nodes.map((node) => node.ref.number).sort()).toEqual([1, 2, 3, 4]); + expect(result.edges).toEqual([ + { child: 2, parent: 1, certainty: "confirmed" }, + { child: 3, parent: 2, certainty: "confirmed" }, + { child: 4, parent: 1, certainty: "confirmed" }, + ]); + expect(result.coverage).toBe("complete"); + }); + + it("keeps a single Gitea match candidate when the bounded listing is partial", () => { + const result = context( + [row(1, "migration", "main"), row(2, "api", "migration")], + 2, + false, + "gitea", + ); + + expect(result.edges).toEqual([{ child: 2, parent: 1, certainty: "candidate" }]); + expect(result.coverage).toBe("partial"); + }); + + it("reports partial coverage when the requested focus is absent from a complete listing", () => { + const result = context([row(1, "migration", "main")], 2); + + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + expect(result.coverage).toBe("partial"); + }); + + it("matches GitHub repository identity using the host's case-insensitive semantics", () => { + const result = context([ + row(1, "migration", "main", "Acme/Web"), + row(2, "api", "migration", "acme/web"), + ]); + + expect(result.edges).toEqual([{ child: 2, parent: 1, certainty: "confirmed" }]); + }); + + it("never confirms an unknown repository identity and ignores a known fork", () => { + const result = context([ + row(1, "migration", "main", null), + row(2, "api", "migration"), + row(9, "migration", "main", "somebody/fork"), + ]); + + expect(result.edges).toEqual([{ child: 2, parent: 1, certainty: "candidate" }]); + expect(result.nodes.map((node) => node.ref.number).sort()).toEqual([1, 2]); + expect(result.issues).toContainEqual({ number: 1, reason: "identity-unknown" }); + }); + + it("keeps an unavailable source as a candidate child but never as a live parent", () => { + const unavailable = { ...row(2, "retained-head", "base-head"), headBranchAvailable: false }; + const result = context([ + row(1, "base-head", "main"), + unavailable, + row(3, "live-child", "retained-head"), + ]); + + expect(result.edges).toEqual([{ child: 2, parent: 1, certainty: "candidate" }]); + expect(result.nodes.find((node) => node.ref.number === 2)?.head).toBeNull(); + expect(result.nodes.some((node) => node.ref.number === 3)).toBe(false); + expect(result.issues).toContainEqual({ number: 2, reason: "source-unavailable" }); + expect(result.coverage).toBe("partial"); + }); + + it("does not make a known chain partial because of an unrelated unknown source identity", () => { + const result = context([ + row(1, "migration", "main"), + row(2, "api", "migration"), + row(9, "unrelated", "main", null), + ]); + + expect(result.coverage).toBe("complete"); + expect(result.issues).toEqual([]); + expect(result.edges).toEqual([{ child: 2, parent: 1, certainty: "confirmed" }]); + }); + + it("caps oversized components to the wire schema while retaining the focus and valid endpoints", () => { + const rows = Array.from({ length: 401 }, (_, index) => + row(index + 1, `branch-${index + 1}`, `branch-${index}`), + ); + const result = context(rows, 401); + const numbers = new Set(result.nodes.map((node) => node.ref.number)); + + expect(result.nodes).toHaveLength(300); + expect(numbers.has(401)).toBe(true); + expect(result.edges.length).toBeGreaterThan(0); + expect(result.edges.every((edge) => numbers.has(edge.child) && numbers.has(edge.parent))).toBe( + true, + ); + expect(result.edges.every((edge) => edge.certainty === "candidate")).toBe(true); + expect(result.coverage).toBe("partial"); + expect(result.issues).toContainEqual({ reason: "budget" }); + expect(Schema.is(PullRequestDependencyContext)(result)).toBe(true); + }); + + it("retains the nearest relationships when listing order puts a direct parent last", () => { + const rows = [ + ...Array.from({ length: 399 }, (_, index) => + row(index + 2, `branch-${index + 2}`, `branch-${index + 1}`), + ), + row(1, "branch-1", "branch-401"), + row(401, "branch-401", "main"), + ]; + const result = context(rows, 1); + const numbers = new Set(result.nodes.map((node) => node.ref.number)); + + expect(result.nodes).toHaveLength(300); + expect(numbers.has(1)).toBe(true); + expect(numbers.has(2)).toBe(true); + expect(numbers.has(401)).toBe(true); + expect(numbers.has(300)).toBe(false); + expect(result.edges).toContainEqual({ child: 1, parent: 401, certainty: "candidate" }); + expect(result.coverage).toBe("partial"); + expect(result.issues).toContainEqual({ reason: "budget" }); + }); + + it("discovers the nearest relationships when the focus follows the global edge budget", () => { + const rows = Array.from({ length: 501 }, (_, index) => + row(index + 1, `branch-${index + 1}`, `branch-${index}`), + ); + const result = context(rows, 501); + const numbers = new Set(result.nodes.map((node) => node.ref.number)); + + expect(result.nodes).toHaveLength(300); + expect(numbers.has(501)).toBe(true); + expect(numbers.has(500)).toBe(true); + expect(result.edges).toContainEqual({ child: 501, parent: 500, certainty: "candidate" }); + expect(result.coverage).toBe("partial"); + expect(result.issues).toContainEqual({ reason: "budget" }); + }); + + it("does not spend the focus edge budget on an unrelated dense component", () => { + const unrelated = Array.from({ length: 25 }, (_, index) => row(index + 1, "shared", "shared")); + const result = context( + [...unrelated, row(1_001, "focus-parent", "main"), row(1_002, "focus-child", "focus-parent")], + 1_002, + ); + + expect(result.nodes.map((node) => node.ref.number)).toEqual([1_001, 1_002]); + expect(result.edges).toEqual([{ child: 1_002, parent: 1_001, certainty: "confirmed" }]); + expect(result.coverage).toBe("complete"); + expect(result.issues).toEqual([]); + }); + + it("exposes duplicate qualified heads as ambiguous parent choices", () => { + const result = context([ + row(1, "migration", "main"), + row(5, "migration", "release"), + row(2, "api", "migration"), + ]); + + expect(result.edges).toEqual([ + { child: 2, parent: 1, certainty: "candidate" }, + { child: 2, parent: 5, certainty: "candidate" }, + ]); + expect(result.issues).toContainEqual({ number: 2, reason: "ambiguous-parent" }); + }); + + it("downgrades cycles so they cannot masquerade as an ordered chain", () => { + const result = context([row(1, "one", "two"), row(2, "two", "one")]); + + expect(result.edges).toHaveLength(2); + expect(result.edges).toEqual( + expect.arrayContaining([ + { child: 1, parent: 2, certainty: "candidate" }, + { child: 2, parent: 1, certainty: "candidate" }, + ]), + ); + expect(result.issues).toEqual( + expect.arrayContaining([ + { number: 1, reason: "cycle" }, + { number: 2, reason: "cycle" }, + ]), + ); + }); + + it("bounds dense duplicate heads before cycle analysis without confirming an omitted choice", () => { + const rows = Array.from({ length: 200 }, (_, index) => row(index + 1, "shared", "shared")); + + const result = context(rows, 1); + + expect(result.edges).toHaveLength(400); + expect(result.edges.every((edge) => edge.certainty === "candidate")).toBe(true); + expect(result.coverage).toBe("partial"); + expect(result.issues).toContainEqual({ reason: "budget" }); + expect(result.issues.length).toBeLessThanOrEqual(400); + }); + + it("reports partial coverage when only the issue budget is exhausted", () => { + const rows = Array.from({ length: 200 }, (_, index) => { + const group = Math.floor(index / 2); + return row(index + 1, `group-${group}`, `group-${(group + 1) % 100}`); + }); + const result = context(rows, 1); + + expect(result.nodes).toHaveLength(200); + expect(result.edges).toHaveLength(400); + expect(result.issues).toHaveLength(398); + expect(result.issues).toContainEqual({ reason: "budget" }); + expect(result.coverage).toBe("partial"); + expect(Schema.is(PullRequestDependencyContext)(result)).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestDependencyTopology.ts b/apps/server/src/pullRequest/pullRequestDependencyTopology.ts new file mode 100644 index 000000000000..4b8b65284908 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestDependencyTopology.ts @@ -0,0 +1,310 @@ +import type { + ProjectId, + PullRequestDependencyContext, + PullRequestDependencyEdge, + PullRequestDependencyIssue, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +const MAX_EDGES = 400; +const MAX_NODES = 300; +/** Leave two slots for repository-read issues added by the service. */ +const MAX_TOPOLOGY_ISSUES = 398; + +export interface ProviderDependencyNode { + readonly number: number; + readonly title: string; + readonly url: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft: boolean; + readonly headBranch: string; + readonly headBranchAvailable?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly baseBranch: string; +} + +export interface PullRequestDependencyTopologyInput { + readonly projectId: ProjectId; + readonly provider: SourceControlProviderKind; + readonly host: string; + readonly repository: string; + readonly focus: number; + readonly rows: ReadonlyArray; + /** Whether the unfiltered relationship listing reached the end of the host's collection. */ + readonly complete: boolean; +} + +// GitHub repository names are case-insensitive. Preserve case elsewhere because a generic helper +// cannot assume the same of every self-hosted provider; adapters own any further canonicalization. +const normalizedRepository = (provider: SourceControlProviderKind, repository: string) => + provider === "github" ? repository.trim().toLowerCase() : repository.trim(); + +/** + * Relates ordinary pull requests using only repository-qualified branch identities. The result is + * the undirected component around the requested pull request; edge direction remains child to + * parent so siblings stay siblings rather than being flattened into an invented order. + */ +export function buildPullRequestDependencyContext( + input: PullRequestDependencyTopologyInput, +): PullRequestDependencyContext { + const repository = normalizedRepository(input.provider, input.repository); + const byNumber = new Map(); + for (const row of input.rows) { + if (!byNumber.has(row.number)) byNumber.set(row.number, row); + } + + const issues: PullRequestDependencyIssue[] = []; + const issueKeys = new Set(); + const addIssue = (issue: PullRequestDependencyIssue) => { + const key = `${issue.number ?? "context"}:${issue.reason}`; + if (issueKeys.has(key)) return; + issueKeys.add(key); + issues.push(issue); + }; + for (const row of byNumber.values()) { + if (row.headBranchAvailable === false) + addIssue({ number: row.number, reason: "source-unavailable" }); + } + + const openByHeadBranch = new Map(); + const openByBaseBranch = new Map(); + const eligibleParentNumbers = new Set(); + for (const row of byNumber.values()) { + if (row.state !== "open") continue; + const existingChildren = openByBaseBranch.get(row.baseBranch); + if (existingChildren === undefined) openByBaseBranch.set(row.baseBranch, [row]); + else existingChildren.push(row); + if (row.headBranchAvailable === false) continue; + if ( + row.headRepositoryNameWithOwner != null && + normalizedRepository(input.provider, row.headRepositoryNameWithOwner) !== repository + ) { + continue; + } + const existing = openByHeadBranch.get(row.headBranch); + if (existing === undefined) openByHeadBranch.set(row.headBranch, [row]); + else existing.push(row); + eligibleParentNumbers.add(row.number); + } + + const edges: PullRequestDependencyEdge[] = []; + const edgeKeys = new Set(); + const queuedNumbers = new Set([input.focus]); + const pendingNumbers = [input.focus]; + let edgeBudgetExhausted = false; + const candidateParentCount = (child: ProviderDependencyNode) => + (openByHeadBranch.get(child.baseBranch)?.length ?? 0) - + (eligibleParentNumbers.has(child.number) && child.headBranch === child.baseBranch ? 1 : 0); + const enqueue = (number: number) => { + if (queuedNumbers.has(number)) return; + queuedNumbers.add(number); + pendingNumbers.push(number); + }; + const addEdge = (child: ProviderDependencyNode, parent: ProviderDependencyNode) => { + const key = `${child.number}:${parent.number}`; + if (edgeKeys.has(key)) return true; + if (edges.length === MAX_EDGES) { + edgeBudgetExhausted = true; + return false; + } + edgeKeys.add(key); + const ambiguous = candidateParentCount(child) > 1; + if (ambiguous) addIssue({ number: child.number, reason: "ambiguous-parent" }); + const identityKnown = parent.headRepositoryNameWithOwner != null; + if (!identityKnown) addIssue({ number: parent.number, reason: "identity-unknown" }); + edges.push({ + child: child.number, + parent: parent.number, + certainty: + input.complete && !ambiguous && identityKnown && child.headBranchAvailable !== false + ? "confirmed" + : "candidate", + }); + enqueue(child.number); + enqueue(parent.number); + return true; + }; + + edgeTraversal: for (let index = 0; index < pendingNumbers.length; index += 1) { + const row = byNumber.get(pendingNumbers[index]!); + if (row === undefined) continue; + if (row.state === "open") { + for (const parent of openByHeadBranch.get(row.baseBranch) ?? []) { + if (parent.number === row.number) continue; + if (!addEdge(row, parent)) break edgeTraversal; + } + } + if (eligibleParentNumbers.has(row.number)) { + for (const child of openByBaseBranch.get(row.headBranch) ?? []) { + if (child.number === row.number) continue; + if (!addEdge(child, row)) break edgeTraversal; + } + } + } + + // A capped topology omits known relationships, so no retained edge may drive definitive ordering. + if (edgeBudgetExhausted) { + for (let index = 0; index < edges.length; index += 1) { + edges[index] = { ...edges[index]!, certainty: "candidate" }; + } + addIssue({ reason: "budget" }); + } + + const parentsByChild = new Map(); + for (const edge of edges) { + const parents = parentsByChild.get(edge.child); + if (parents === undefined) parentsByChild.set(edge.child, [edge.parent]); + else parents.push(edge.parent); + } + // Tarjan's strongly connected components keep cycle detection linear in the retained graph. + // The edge cap above is therefore also a CPU bound, even for hundreds of duplicate heads. + let nextIndex = 0; + const indices = new Map(); + const lowLinks = new Map(); + const stack: number[] = []; + const onStack = new Set(); + const componentByNumber = new Map(); + const componentSizes: number[] = []; + const visit = (number: number) => { + const index = nextIndex++; + indices.set(number, index); + lowLinks.set(number, index); + stack.push(number); + onStack.add(number); + for (const parent of parentsByChild.get(number) ?? []) { + if (!indices.has(parent)) { + visit(parent); + lowLinks.set(number, Math.min(lowLinks.get(number)!, lowLinks.get(parent)!)); + } else if (onStack.has(parent)) { + lowLinks.set(number, Math.min(lowLinks.get(number)!, indices.get(parent)!)); + } + } + if (lowLinks.get(number) !== indices.get(number)) return; + const component = componentSizes.length; + let size = 0; + while (stack.length > 0) { + const member = stack.pop()!; + onStack.delete(member); + componentByNumber.set(member, component); + size += 1; + if (member === number) break; + } + componentSizes.push(size); + }; + for (const edge of edges) { + if (!indices.has(edge.child)) visit(edge.child); + if (!indices.has(edge.parent)) visit(edge.parent); + } + const cycleEdges = new Set(); + edges.forEach((edge, index) => { + const component = componentByNumber.get(edge.child); + if ( + component === undefined || + component !== componentByNumber.get(edge.parent) || + componentSizes[component] === 1 + ) { + return; + } + cycleEdges.add(index); + addIssue({ number: edge.child, reason: "cycle" }); + addIssue({ number: edge.parent, reason: "cycle" }); + }); + const safeEdges = edges.map((edge, index) => + cycleEdges.has(index) ? { ...edge, certainty: "candidate" as const } : edge, + ); + + const neighborsByNumber = new Map(); + for (const edge of safeEdges) { + const childNeighbors = neighborsByNumber.get(edge.child) ?? []; + childNeighbors.push(edge.parent); + neighborsByNumber.set(edge.child, childNeighbors); + const parentNeighbors = neighborsByNumber.get(edge.parent) ?? []; + parentNeighbors.push(edge.child); + neighborsByNumber.set(edge.parent, parentNeighbors); + } + const connected = new Set([input.focus]); + const nearestFirst = [input.focus]; + for (let index = 0; index < nearestFirst.length; index += 1) { + for (const neighbor of neighborsByNumber.get(nearestFirst[index]!) ?? []) { + if (!connected.has(neighbor)) { + connected.add(neighbor); + nearestFirst.push(neighbor); + } + } + } + + // Breadth-first order keeps the selected pull request and its nearest relationships when capped. + const retained = new Set(nearestFirst.slice(0, MAX_NODES)); + const nodeBudgetExhausted = retained.size < connected.size; + const componentEdges = safeEdges + .filter((edge) => retained.has(edge.child) && retained.has(edge.parent)) + .map((edge) => (nodeBudgetExhausted ? { ...edge, certainty: "candidate" as const } : edge)); + if (nodeBudgetExhausted) addIssue({ reason: "budget" }); + + let componentIssues = issues.filter( + (issue) => issue.number === undefined || retained.has(issue.number), + ); + const hasUnknownIdentity = [...byNumber.values()].some( + (row) => connected.has(row.number) && row.headRepositoryNameWithOwner == null, + ); + const hasUnavailableSource = [...byNumber.values()].some( + (row) => row.headBranchAvailable === false, + ); + if (hasUnknownIdentity && !componentIssues.some((issue) => issue.reason === "identity-unknown")) { + componentIssues.push({ reason: "identity-unknown" }); + } + if ( + hasUnavailableSource && + !componentIssues.some((issue) => issue.reason === "source-unavailable") + ) { + componentIssues.push({ reason: "source-unavailable" }); + } + const issueBudgetExhausted = componentIssues.length > MAX_TOPOLOGY_ISSUES; + if (issueBudgetExhausted) { + componentIssues = componentIssues.slice(0, MAX_TOPOLOGY_ISSUES - 1); + if (!componentIssues.some((issue) => issue.reason === "budget")) { + componentIssues.push({ reason: "budget" }); + } + } + + return { + focus: { + projectId: input.projectId, + repository: input.repository, + number: input.focus, + }, + provider: input.provider, + host: input.host, + repository: input.repository, + nodes: [...byNumber.values()] + .filter((row) => retained.has(row.number)) + .map((row) => ({ + ref: { + projectId: input.projectId, + repository: input.repository, + number: row.number, + }, + title: row.title, + url: row.url, + state: row.state, + isDraft: row.isDraft, + baseBranch: row.baseBranch, + head: + row.headRepositoryNameWithOwner == null || row.headBranchAvailable === false + ? null + : { repository: row.headRepositoryNameWithOwner, branch: row.headBranch }, + })), + edges: componentEdges, + coverage: + input.complete && + byNumber.has(input.focus) && + !hasUnknownIdentity && + !hasUnavailableSource && + !edgeBudgetExhausted && + !nodeBudgetExhausted && + !issueBudgetExhausted + ? "complete" + : "partial", + issues: componentIssues, + }; +} diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 480def670235..7b78b10992d9 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestActionInput, PullRequestCapabilities, + PullRequestDependencyContext, PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, @@ -237,6 +238,16 @@ describe("PullRequestCapabilities", () => { it("decodes a server that says nothing about reactions as a server with none", () => { expect(decodeCapabilities(base).reactions).toBeUndefined(); + expect(decodeCapabilities(base).dependencies).toBeUndefined(); + }); + + it("adds dependency capabilities without changing older server responses", () => { + expect( + decodeCapabilities({ + ...base, + dependencies: { branchRelationships: true, nativeMembership: false }, + }).dependencies, + ).toEqual({ branchRelationships: true, nativeMembership: false }); }); it("keeps the legacy all-subject reaction flag while allowing an exact subject gate", () => { @@ -259,6 +270,36 @@ describe("PullRequestCapabilities", () => { }); }); +describe("PullRequestDependencyContext", () => { + it("round-trips bounded branch and native membership data through the RPC codec", () => { + const projectId = "p1" as PullRequestDependencyContext["focus"]["projectId"]; + const value: PullRequestDependencyContext = { + focus: { projectId, repository: "acme/web", number: 2 }, + provider: "github", + host: "github.com", + repository: "acme/web", + nodes: [ + { + ref: { projectId, repository: "acme/web", number: 2 }, + title: "API", + url: "https://github.com/acme/web/pull/2", + state: "open", + isDraft: false, + baseBranch: "migration", + head: { repository: "acme/web", branch: "api" }, + }, + ], + edges: [{ child: 2, parent: 1, certainty: "confirmed" }], + coverage: "complete", + issues: [], + native: { status: "present", id: "STACK_1", members: [1, 2], coverage: "complete" }, + }; + const codec = Schema.toCodecJson(PullRequestDependencyContext); + + expect(Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(value))).toEqual(value); + }); +}); + describe("naming the reader as the author to narrow by", () => { it("reads me as whoever is signed in, however it is written", () => { expect(resolvePullRequestAuthorFilter("me", "octocat")).toBe("octocat"); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 74ea49ceb9e2..d17258352eda 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -408,6 +408,15 @@ export const PullRequestReviewerCapabilities = Schema.Struct({ }); export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type; +/** Read-only dependency information a provider can contribute to the pull request panel. */ +export const PullRequestDependencyCapabilities = Schema.Struct({ + /** Ordinary pull requests can be related by their repository-qualified head and base refs. */ + branchRelationships: Schema.Boolean, + /** The host can additionally report an explicit, ordered native stack membership. */ + nativeMembership: Schema.Boolean, +}); +export type PullRequestDependencyCapabilities = typeof PullRequestDependencyCapabilities.Type; + /** * What a provider can actually do, so a surface can hide what is missing rather than offer an * action that would fail. Every provider fills this in for itself; nothing is assumed. @@ -463,6 +472,11 @@ export const PullRequestCapabilities = Schema.Struct({ * to change them, which is what every server before this field was. */ labels: Schema.optional(Schema.Boolean), + /** + * Dependency reads are optional so clients remain compatible with servers that predate them. + * An absent value means the client must leave dependency navigation unavailable. + */ + dependencies: Schema.optional(PullRequestDependencyCapabilities), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; @@ -667,6 +681,81 @@ export const PullRequestRef = Schema.Struct({ }); export type PullRequestRef = typeof PullRequestRef.Type; +export const PullRequestDependencyCoverage = Schema.Literals([ + "complete", + "partial", + "unavailable", +]); +export type PullRequestDependencyCoverage = typeof PullRequestDependencyCoverage.Type; + +export const PullRequestDependencyNode = Schema.Struct({ + ref: PullRequestRef, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: PullRequestState, + isDraft: Schema.Boolean, + baseBranch: TrimmedNonEmptyString, + /** Null when the host did not qualify the source branch with its repository identity. */ + head: Schema.NullOr( + Schema.Struct({ + repository: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, + }), + ), +}); +export type PullRequestDependencyNode = typeof PullRequestDependencyNode.Type; + +export const PullRequestDependencyEdge = Schema.Struct({ + /** The pull request whose base targets the parent's head branch. */ + child: PositiveInt, + parent: PositiveInt, + /** Only a unique, repository-qualified match from a complete read is confirmed. */ + certainty: Schema.Literals(["confirmed", "candidate"]), +}); +export type PullRequestDependencyEdge = typeof PullRequestDependencyEdge.Type; + +export const PullRequestDependencyIssue = Schema.Struct({ + number: Schema.optional(PositiveInt), + reason: Schema.Literals([ + "budget", + "identity-unknown", + "source-unavailable", + "ambiguous-parent", + "cycle", + "host-unavailable", + ]), +}); +export type PullRequestDependencyIssue = typeof PullRequestDependencyIssue.Type; + +export const PullRequestNativeDependencyMembership = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("present"), + /** Opaque outside the provider and scoped to this context's host and repository. */ + id: TrimmedNonEmptyString, + members: Schema.Array(PositiveInt).check(Schema.isMaxLength(100)), + coverage: Schema.Literals(["complete", "partial"]), + }), + Schema.Struct({ status: Schema.Literals(["none", "unavailable"]) }), +]); +export type PullRequestNativeDependencyMembership = + typeof PullRequestNativeDependencyMembership.Type; + +/** A bounded dependency read for one exact provider, host, and target repository. */ +export const PullRequestDependencyContext = Schema.Struct({ + focus: PullRequestRef, + provider: SourceControlProviderKind, + host: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + /** Up to 200 relationship rows plus 100 additional native members. */ + nodes: Schema.Array(PullRequestDependencyNode).check(Schema.isMaxLength(300)), + edges: Schema.Array(PullRequestDependencyEdge).check(Schema.isMaxLength(400)), + coverage: PullRequestDependencyCoverage, + issues: Schema.Array(PullRequestDependencyIssue).check(Schema.isMaxLength(400)), + /** Omitted until a provider attempts a native membership read. */ + native: Schema.optional(PullRequestNativeDependencyMembership), +}); +export type PullRequestDependencyContext = typeof PullRequestDependencyContext.Type; + /** * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar * status check never loads permissions, repository settings, checks, or base comparison data.