diff --git a/backend/src/routes/pullRequestRoutes.ts b/backend/src/routes/pullRequestRoutes.ts index d04857252..1e4404737 100644 --- a/backend/src/routes/pullRequestRoutes.ts +++ b/backend/src/routes/pullRequestRoutes.ts @@ -11,7 +11,9 @@ import type { import { parseDashboardRollbackRequest, parsePullRequestApproveRequest, + parsePullRequestPreviewStartRequest, parsePullRequestRejectRequest, + parsePullRequestStackCreateRequest, } from "../../../contracts/delivery.ts"; import { json, jsonWithEtag } from "../http.ts"; import { CoalescedSnapshot } from "../lib/coalescedSnapshot.ts"; @@ -38,6 +40,7 @@ import { runPullRequestBranchUpdate, runPullRequestRejection, runPullRequestReviewApproval, + runPullRequestStackCreation, validatePrNumber, } from "../services/pullRequests.ts"; @@ -122,21 +125,51 @@ export const pullRequestRoutes = { } }, }, + "/api/pull-requests/stacks": { + POST: async (request: Request) => { + try { + const body = await readApiJsonOrError( + request, + parsePullRequestStackCreateRequest, + { + code: "invalid_pull_request_stack", + context: "pull-request.stack", + message: "Invalid pull request stack", + } + ); + if (body instanceof Response) return body; + const response = await runPullRequestMutation(() => + runPullRequestStackCreation(body.pullRequests) + ); + return json(response satisfies PullRequestActionResponse, { + status: 201, + }); + } catch (error) { + return routeError(error, "Pull request stack creation failed"); + } + }, + }, "/api/pull-requests/:number/approve": { POST: async (request: ParametersRequest<"number">) => { const number = parsePullRequestNumber(request.params.number); if (number instanceof Response) return number; try { - const body = request.body - ? await readApiJsonOrError(request, parsePullRequestApproveRequest, { - code: "invalid_pull_request_approval", - context: "pull-request.approve", - message: "Invalid pull request approval", - }) - : {}; + const body = await readApiJsonOrError( + request, + parsePullRequestApproveRequest, + { + code: "invalid_pull_request_approval", + context: "pull-request.approve", + message: "Invalid pull request approval", + } + ); if (body instanceof Response) return body; const response = await runPullRequestMutation(() => - runPullRequestApproval(number, body?.deploy === true) + runPullRequestApproval(number, body?.deploy === true, { + expectedHeadSha: body.expectedHeadSha, + expectedStackHeads: body.expectedStackHeads, + mergeStack: body?.mergeStack, + }) ); return json(response satisfies PullRequestActionResponse); } catch (error) { @@ -203,11 +236,24 @@ export const pullRequestRoutes = { const number = parsePullRequestNumber(request.params.number); if (number instanceof Response) return number; try { + const body = await readApiJsonOrError( + request, + parsePullRequestPreviewStartRequest, + { + code: "invalid_pull_request_preview", + context: "pull-request.preview", + message: "Invalid pull request preview request", + } + ); + if (body instanceof Response) return body; try { return json( { isOk: true, - preview: await prepareAndStartPullRequestPreview(number), + preview: await prepareAndStartPullRequestPreview( + number, + body.expectedHeadSha + ), } satisfies PullRequestPreviewMutationResponse, { status: 202 } ); diff --git a/backend/src/services/pullRequestPreviewHost.ts b/backend/src/services/pullRequestPreviewHost.ts index aafd1f01e..090c0d375 100644 --- a/backend/src/services/pullRequestPreviewHost.ts +++ b/backend/src/services/pullRequestPreviewHost.ts @@ -75,10 +75,10 @@ const SAFE_INSTALL_ENVIRONMENT_KEYS = [ ] as const; export interface PullRequestPreviewCandidate { - authorLogin?: string; - baseRefName: string; + authorLogins: Array; commitSha: string; number: number; + rootBaseRefName: string; title: string; } @@ -1581,17 +1581,24 @@ function validatePreviewPullRequest( ) { throw new TypeError("Preview pull request number is invalid"); } - if (pullRequest.baseRefName !== "main") { + if (pullRequest.rootBaseRefName !== "main") { throw Object.assign( - new Error("Only main-targeted pull requests can be previewed"), + new Error("Only main-rooted pull requests can be previewed"), { statusCode: 409 } ); } if ( - !isPullRequestPreviewAuthorAllowed(pullRequest.authorLogin, config.allowedAuthors) + pullRequest.authorLogins.length === 0 || + pullRequest.authorLogins.length > 100 || + pullRequest.authorLogins.some( + (authorLogin) => + !isPullRequestPreviewAuthorAllowed(authorLogin, config.allowedAuthors) + ) ) { throw Object.assign( - new Error("Pull request author is not allowed to run host previews"), + new Error( + "Every pull request included in a host preview must have an allowed author" + ), { statusCode: 403 } ); } diff --git a/backend/src/services/pullRequestPreviews.ts b/backend/src/services/pullRequestPreviews.ts index 6026a1e0e..b637cd0c5 100644 --- a/backend/src/services/pullRequestPreviews.ts +++ b/backend/src/services/pullRequestPreviews.ts @@ -20,6 +20,8 @@ import { import { isDashboardPullRequestOpen, listDashboardPullRequests, + pullRequestPreviewScope, + validatePullRequestPreviewScope, validatePrNumber, } from "./pullRequests.ts"; import { @@ -65,17 +67,24 @@ function executionPreviewCommitSha(value: unknown): string { } /** - * Converts a GitHub PR summary into the constrained host-preview contract. + * Converts a GitHub PR summary and its included layers into the host-preview contract. + * @param pullRequest Selected pull request. + * @param scope Main-rooted pull requests included in the selected head. * @returns Converted a GitHub PR summary into the constrained host-preview contract. */ export function pullRequestPreviewCandidate( - pullRequest: PullRequestSummary + pullRequest: PullRequestSummary, + scope: readonly PullRequestSummary[] = [pullRequest] ): PullRequestPreviewCandidate { + const rootPullRequest = scope[0]; return { - authorLogin: pullRequest.author?.login, - baseRefName: pullRequest.baseRefName, + authorLogins: scope.map((candidate) => candidate.author?.login), commitSha: pullRequest.headRefOid || "", number: pullRequest.number, + rootBaseRefName: + rootPullRequest?.stack?.baseRefName ?? + rootPullRequest?.baseRefName ?? + pullRequest.baseRefName, title: pullRequest.title, }; } @@ -88,7 +97,17 @@ async function findPullRequest(number: number): Promise { const unavailable = unavailablePreviewControls(); if (unavailable) return unavailable; + const expectedCommit = executionPreviewCommitSha(expectedHeadSha); const candidate = await findPullRequest(number); + if (candidate.commitSha !== expectedCommit) { + throw Object.assign( + new Error( + `PR #${number} changed after Delivery loaded it. Review the new head before starting dev` + ), + { statusCode: 409 } + ); + } const current = await getPullRequestPreviewStatus(); if ( ["running", "starting", "stopping"].includes(current.status) && @@ -297,7 +327,7 @@ export async function prepareAndStartPullRequestPreview( actionKey: "dashboard.preview.start", displayName: `Start PR #${number} preview`, payload: { - commitSha: executionPreviewCommitSha(candidate.commitSha), + commitSha: expectedCommit, number, }, resourceClass: "exclusive", diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index af4cb604d..03e729a44 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -4,13 +4,21 @@ import type { DashboardReleaseStatus, DashboardReleaseSummary, DeploymentJob, + GitHubAsyncPullRequestMergeResult, + GitHubPullRequestState, + GitHubPullRequestStackResource, ProductionCheckoutStatus, + PullRequestExpectedHead, PullRequestPreviewCleanupResult, + PullRequestStack, PullRequestSummary, WorktreeCleanupResult, } from "../../../contracts/delivery.ts"; import { + parseGitHubAsyncPullRequestMergeResult, parseGitHubPullRequestState, + parseGitHubPullRequestStackResource, + parseGitHubPullRequestStacks, parsePublicGitHubPullRequests, parsePullRequestSummary, } from "../../../contracts/delivery.ts"; @@ -98,6 +106,50 @@ function pullRequestMergeMessage( return willDeploy ? `PR #${number} merged. Deploy started` : `PR #${number} merged`; } +/** + * Describes the outcome of atomically merging a native pull request stack. + * @param stackNumber GitHub stack number. + * @param number Highest pull request included in the merge. + * @param pullRequestCount Number of open pull requests merged. + * @param willDeploy Whether deployment was requested. + * @param syncError Production checkout synchronization error, when present. + * @param deployError Deployment startup error, when present. + * @returns User-facing stack merge result message. + */ +function pullRequestStackMergeMessage( + stackNumber: number, + number: number, + pullRequestCount: number, + willDeploy: boolean, + syncError: string | undefined, + deployError: string | undefined +): string { + const merged = `Stack #${stackNumber} merged through PR #${number} (${pullRequestCount} PR${pullRequestCount === 1 ? "" : "s"})`; + if (syncError) return `${merged}. Production sync failed`; + if (deployError) return `${merged}. Deploy failed to start`; + return willDeploy ? `${merged}. Deploy started` : merged; +} + +/** + * Describes a native stack accepted by GitHub's merge queue. + * @param stackNumber GitHub stack number. + * @param number Highest pull request included in the queue group. + * @param pullRequestCount Number of open pull requests added to the queue. + * @param willDeploy Whether deployment was requested. + * @returns User-facing queued stack result message. + */ +function pullRequestStackQueuedMessage( + stackNumber: number, + number: number, + pullRequestCount: number, + willDeploy: boolean +): string { + const queued = `Stack #${stackNumber} queued through PR #${number} (${pullRequestCount} PR${pullRequestCount === 1 ? "" : "s"})`; + return willDeploy + ? `${queued}. Delivery retained every worktree and will not auto-deploy; deploy latest main after GitHub finishes the queue` + : `${queued}. Delivery retained every worktree because GitHub has not confirmed the PRs merged`; +} + import { type OrphanedDeploymentCutover, registerDeploymentCutoverRecoveryHandler, @@ -134,6 +186,10 @@ const RECENT_DEPLOYMENTS_LIMIT = 10; const MAX_BUFFER = 20 * 1024 * 1024; const MAX_JSON_LINE_LENGTH = 1024 * 1024; const PR_LIST_TIMEOUT_MS = 180_000; +const STACK_MERGE_POLL_INTERVAL_MS = 1000; +const STACK_MERGE_TIMEOUT_MS = 5 * 60 * 1000; +const STACK_MERGE_JOB_TIMEOUT_MS = STACK_MERGE_TIMEOUT_MS * 2 + 2 * 60 * 1000; +const STACK_GRAPHQL_CAPABILITY_CACHE_MS = 60_000; const PUBLIC_PR_CACHE_MS = 2 * 60 * 1000; const PUBLIC_PR_FAILURE_CACHE_MS = 30_000; const PUBLIC_GITHUB_API_TIMEOUT_MS = 15_000; @@ -155,6 +211,13 @@ const publicPullRequestCache: { failure?: { expiresAt: number; message: string }; value?: { expiresAt: number; pullRequests: PullRequestSummary[] }; } = {}; +let stackGraphqlCapabilityCache: + | { + expiresAt: number; + key: string; + result: Promise; + } + | undefined; export function getResolvedRoots() { return { @@ -851,6 +914,11 @@ function parseRepoParts(repo: string): { owner: string; name: string } { return { owner, name }; } +function pullRequestStacksEndpoint(): string { + const repo = parseRepoParts(DASHBOARD_REPO); + return `repos/${repo.owner}/${repo.name}/stacks`; +} + /** * Builds GitHub command environment for one token. * @param githubToken Github token value. @@ -969,42 +1037,232 @@ function normalizePullRequest(pr: PullRequestSummary): PullRequestSummary { const rest = { ...pr }; delete rest.latestOpinionatedReviews; delete rest.reviews; - const previewAllowedAuthors = resolvePullRequestPreviewAllowedAuthors(); return { ...rest, - canReviewerApprove: canReviewerApprove(pr), - previewEligible: - pr.baseRefName === DEFAULT_BASE && - isPullRequestPreviewAuthorAllowed(pr.author?.login, previewAllowedAuthors) && - typeof pr.headRefOid === "string" && - FULL_COMMIT_SHA_PATTERN.test(pr.headRefOid), reviewerApproved: isPullRequestReviewApproved(pr), }; } +interface PullRequestPreviewScopeIndex { + candidateScopes: Map; + nativeStackMembers: Map; +} + +function buildPullRequestPreviewScopeIndex( + pullRequests: readonly PullRequestSummary[] +): PullRequestPreviewScopeIndex { + const candidateScopes = new Map(); + const nativeStackMembers = new Map(); + const unstackedPullRequests: PullRequestSummary[] = []; + + for (const pullRequest of pullRequests) { + if (pullRequest.stack) { + const members = nativeStackMembers.get(pullRequest.stack.number) ?? []; + members.push(pullRequest); + nativeStackMembers.set(pullRequest.stack.number, members); + continue; + } + if (pullRequest.isCrossRepository === true) { + continue; + } + unstackedPullRequests.push(pullRequest); + if (pullRequest.baseRefName === DEFAULT_BASE) { + candidateScopes.set(pullRequest.number, [pullRequest]); + } + } + + for (const members of nativeStackMembers.values()) { + members.sort( + (left, right) => (left.stack?.position ?? 0) - (right.stack?.position ?? 0) + ); + } + + const childrenByBase = new Map(); + for (const candidate of unstackedPullRequests) { + const children = childrenByBase.get(candidate.baseRefName) ?? []; + children.push(candidate); + childrenByBase.set(candidate.baseRefName, children); + } + + for (const bottomPullRequest of unstackedPullRequests) { + if (bottomPullRequest.baseRefName !== DEFAULT_BASE) continue; + const members = [bottomPullRequest]; + const seenNumbers = new Set([bottomPullRequest.number]); + let currentPullRequest = bottomPullRequest; + let isLinear = true; + while (true) { + const children = childrenByBase.get(currentPullRequest.headRefName) ?? []; + if (children.length === 0) break; + if (children.length !== 1) { + isLinear = false; + break; + } + const child = children[0]; + if (!child || seenNumbers.has(child.number)) { + isLinear = false; + break; + } + members.push(child); + seenNumbers.add(child.number); + currentPullRequest = child; + } + if (!isLinear) continue; + for (let memberIndex = 1; memberIndex < members.length; memberIndex += 1) { + const member = members[memberIndex]; + if (member) { + candidateScopes.set(member.number, members.slice(0, memberIndex + 1)); + } + } + } + + return { candidateScopes, nativeStackMembers }; +} + +function resolvePullRequestPreviewScope( + pullRequest: PullRequestSummary, + index: PullRequestPreviewScopeIndex +): PullRequestSummary[] | undefined { + const selectedStack = pullRequest.stack; + if (!selectedStack) return index.candidateScopes.get(pullRequest.number); + if (selectedStack.baseRefName !== DEFAULT_BASE) return undefined; + const members = (index.nativeStackMembers.get(selectedStack.number) ?? []).filter( + (candidate) => (candidate.stack?.position ?? 0) <= selectedStack.position + ); + return members.at(-1)?.number === pullRequest.number ? members : undefined; +} + +/** + * Finds the main-rooted pull requests whose code is included in one PR head. + * The result is ordered bottom-to-top and excludes already-merged stack layers. + * @param pullRequest Selected pull request. + * @param pullRequests Current open pull requests. + * @returns Included pull requests, or undefined when no trusted linear ancestry exists. + */ +export function pullRequestPreviewScope( + pullRequest: PullRequestSummary, + pullRequests: readonly PullRequestSummary[] +): PullRequestSummary[] | undefined { + return resolvePullRequestPreviewScope( + pullRequest, + buildPullRequestPreviewScopeIndex(pullRequests) + ); +} + +/** + * Verifies that every unmerged native stack layer included in a preview remains + * open and matches the exact metadata used for author authorization. + * @param pullRequest Selected pull request. + * @param scope Open pull request layers included in its head. + * @param signal Signal used to cancel the operation. + */ +export async function validatePullRequestPreviewScope( + pullRequest: PullRequestSummary, + scope: readonly PullRequestSummary[], + signal?: AbortSignal +): Promise { + if (!pullRequest.stack) return; + const stack = await requirePullRequestStack(pullRequest.number, signal); + validateDashboardStackMembership(pullRequest, stack); + const selectedIndex = stack.pull_requests.findIndex( + (candidate) => candidate.number === pullRequest.number + ); + if (selectedIndex === -1) { + throw Object.assign( + new Error(`PR #${pullRequest.number} is no longer in its GitHub stack`), + { statusCode: 409 } + ); + } + const scopeByNumber = new Map( + scope.map((candidate) => [candidate.number, candidate]) + ); + for (const stackPullRequest of stack.pull_requests.slice(0, selectedIndex + 1)) { + if (stackPullRequest.merged_at !== null) continue; + if (stackPullRequest.state !== "open") { + throw Object.assign( + new Error( + `PR #${stackPullRequest.number} is closed and blocks this stack preview` + ), + { statusCode: 409 } + ); + } + const scopedPullRequest = scopeByNumber.get(stackPullRequest.number); + if ( + !scopedPullRequest || + scopedPullRequest.headRefOid !== stackPullRequest.head.sha + ) { + throw Object.assign( + new Error( + `PR #${stackPullRequest.number} changed while Delivery loaded the stack preview` + ), + { statusCode: 409 } + ); + } + } +} + +function applyPullRequestPreviewEligibility( + pullRequests: PullRequestSummary[] +): PullRequestSummary[] { + const allowedAuthors = resolvePullRequestPreviewAllowedAuthors(); + const scopeIndex = buildPullRequestPreviewScopeIndex(pullRequests); + return pullRequests.map((pullRequest) => { + const scope = resolvePullRequestPreviewScope(pullRequest, scopeIndex); + const isReviewApprovalSupported = + scope !== undefined || + (pullRequest.stack === undefined && pullRequest.baseRefName === DEFAULT_BASE); + const previewEligible = + scope !== undefined && + scope.every( + (candidate) => + isPullRequestPreviewAuthorAllowed( + candidate.author?.login, + allowedAuthors + ) && + typeof candidate.headRefOid === "string" && + FULL_COMMIT_SHA_PATTERN.test(candidate.headRefOid) + ); + return { + ...pullRequest, + canReviewerApprove: + isReviewApprovalSupported && canReviewerApprove(pullRequest), + previewEligible, + }; + }); +} + /** * Parses the bounded public REST shape used only by credential-free dev previews. * @param value Value to process. * @returns Parsed the bounded public REST shape used only by credential-free dev previews. */ export function parsePublicGithubPullRequests(value: unknown): PullRequestSummary[] { - return parsePublicGitHubPullRequests(value).map((pullRequest) => { - return normalizePullRequest({ - author: { login: pullRequest.user.login }, - baseRefName: pullRequest.base.ref, - body: pullRequest.body ?? undefined, - createdAt: pullRequest.created_at, - headRefName: pullRequest.head.ref, - headRefOid: pullRequest.head.sha, - isDraft: pullRequest.draft, - number: Number(pullRequest.number), - statusCheckRollup: [], - title: pullRequest.title, - updatedAt: pullRequest.updated_at, - url: pullRequest.html_url, - }); - }); + return applyPullRequestPreviewEligibility( + parsePublicGitHubPullRequests(value).map((pullRequest) => { + return normalizePullRequest({ + author: { login: pullRequest.user.login }, + baseRefName: pullRequest.base.ref, + body: pullRequest.body ?? undefined, + createdAt: pullRequest.created_at, + headRefName: pullRequest.head.ref, + headRefOid: pullRequest.head.sha, + isDraft: pullRequest.draft, + number: Number(pullRequest.number), + stack: pullRequest.stack + ? { + baseRefName: pullRequest.stack.base.ref, + number: pullRequest.stack.number, + position: pullRequest.stack.position, + size: pullRequest.stack.size, + } + : undefined, + statusCheckRollup: [], + title: pullRequest.title, + updatedAt: pullRequest.updated_at, + url: pullRequest.html_url, + }); + }) + ); } async function readBoundedJsonResponse( @@ -1050,7 +1308,7 @@ async function listPublicDashboardPullRequests(): Promise } try { const response = await fetch( - `https://api.github.com/repos/${DASHBOARD_REPO}/pulls?state=open&base=${DEFAULT_BASE}&per_page=100`, + `https://api.github.com/repos/${DASHBOARD_REPO}/pulls?state=open&per_page=100`, { headers: { Accept: "application/vnd.github+json", @@ -1135,19 +1393,21 @@ async function runCommand( * @param arguments_ Arguments value. * @param parser Runtime value parser. * @param signal Signal used to cancel the operation. + * @param timeoutMs Maximum command runtime. * @returns Promise resolving to the run gh json result. */ async function runGhJson( arguments_: string[], parser: ContractParser, - signal?: AbortSignal + signal?: AbortSignal, + timeoutMs = 60_000 ): Promise { const { code, stderr, stdout } = await runProcess("gh", arguments_, { cwd: getDashboardRoot(), env: buildCommandEnvironment(), maxBuffer: MAX_BUFFER, signal, - timeoutMs: 60_000, + timeoutMs, }); if (code !== 0) { throw new Error( @@ -1163,6 +1423,284 @@ async function runGhJson( return parser(JSON.parse(output)); } +class GitHubRestApiError extends Error { + readonly endpoint: string; + readonly statusCode: number | undefined; + + constructor(endpoint: string, statusCode: number | undefined, message: string) { + super(message); + this.name = "GitHubRestApiError"; + this.endpoint = endpoint; + this.statusCode = statusCode; + } +} + +function parseIncludedGitHubResponse(output: string): { + body: string; + statusCode?: number; +} { + const normalizedOutput = output.replaceAll("\r\n", "\n").trim(); + const statusMatch = /^HTTP\/\S+\s+(\d{3})[^\n]*\n/u.exec(normalizedOutput); + if (!statusMatch) return { body: normalizedOutput }; + const bodySeparator = normalizedOutput.indexOf("\n\n", statusMatch[0].length); + if (bodySeparator === -1) { + throw new Error("GitHub CLI included response was missing its body separator"); + } + return { + body: normalizedOutput.slice(bodySeparator + 2).trim(), + statusCode: Number(statusMatch[1]), + }; +} + +/** + * Runs one REST API request with `gh --include` so capability decisions use the + * HTTP status line rather than mutable CLI error prose. + * @param arguments_ GitHub CLI arguments, including `--include`. + * @param endpoint REST endpoint used to scope capability errors. + * @param parser Runtime value parser. + * @param signal Signal used to cancel the operation. + * @returns Parsed GitHub REST response. + */ +async function runGhRestJson( + arguments_: string[], + endpoint: string, + parser: ContractParser, + signal?: AbortSignal +): Promise { + const { code, stderr, stdout } = await runProcess("gh", arguments_, { + cwd: getDashboardRoot(), + env: buildCommandEnvironment(), + maxBuffer: MAX_BUFFER, + signal, + timeoutMs: 60_000, + }); + const response = parseIncludedGitHubResponse(stdout); + if (code !== 0 || (response.statusCode !== undefined && response.statusCode >= 400)) { + throw new GitHubRestApiError( + endpoint, + response.statusCode, + `GitHub API ${endpoint} failed${ + response.statusCode === undefined + ? ` with exit code ${code}` + : ` with status ${response.statusCode}` + }: ${stderr.trim() || response.body || "GitHub CLI returned no result"}` + ); + } + if (!response.body) { + throw new Error("GitHub CLI returned an empty JSON response"); + } + return parser(JSON.parse(response.body)); +} + +/** + * Runs a GitHub API command whose documented terminal error states use JSON bodies. + * @param arguments_ GitHub CLI arguments. + * @param parser Runtime value parser. + * @param signal Signal used to cancel the operation. + * @param timeoutMs Maximum command runtime. + * @returns Parsed GitHub response, including a documented non-2xx result body. + */ +async function runGhJsonWithResultBody( + arguments_: string[], + parser: ContractParser, + signal?: AbortSignal, + timeoutMs = 60_000 +): Promise { + const { code, stderr, stdout } = await runProcess("gh", arguments_, { + cwd: getDashboardRoot(), + env: buildCommandEnvironment(), + maxBuffer: MAX_BUFFER, + signal, + timeoutMs, + }); + const output = stdout.trim(); + if (output) { + try { + return parser(JSON.parse(output)); + } catch (error) { + if (code === 0) throw error; + } + } + throw new Error( + `gh ${arguments_.join(" ")} failed with exit code ${code}: ${ + stderr.trim() || output || "GitHub CLI returned no result" + }` + ); +} + +/** + * Returns the native GitHub stack containing one pull request. + * @param number Pull request number. + * @param signal Signal used to cancel the operation. + * @returns The native stack, when the pull request is stacked. + */ +async function findPullRequestStack( + number: number, + signal?: AbortSignal +): Promise { + const endpoint = `${pullRequestStacksEndpoint()}?pull_request=${number}&per_page=2`; + const stacks = await runGhRestJson( + ["api", endpoint, "--include"], + endpoint, + parseGitHubPullRequestStacks, + signal + ); + if (stacks.length > 1) { + throw new Error(`GitHub returned multiple stacks for PR #${number}`); + } + return stacks[0]; +} + +/** + * Requires one pull request to belong to a native GitHub stack. + * @param number Pull request number. + * @param signal Signal used to cancel the operation. + * @returns The pull request's native stack. + */ +async function requirePullRequestStack( + number: number, + signal?: AbortSignal +): Promise { + const stack = await findPullRequestStack(number, signal); + if (!stack) { + throw Object.assign( + new Error( + `PR #${number} is not registered as a GitHub stack. Create the stack before merging it` + ), + { statusCode: 409 } + ); + } + return stack; +} + +function isGitHubStackApiUnavailable(error: unknown): boolean { + const endpoint = pullRequestStacksEndpoint(); + return ( + error instanceof GitHubRestApiError && + error.statusCode === 404 && + (error.endpoint === endpoint || error.endpoint.startsWith(`${endpoint}?`)) + ); +} + +/** + * Finds stack membership for ordinary PR mutation guards without breaking + * repositories where the private-preview stack API is unavailable. + * @param number Pull request number. + * @param signal Signal used to cancel the operation. + * @returns Native stack membership, when visible and present. + */ +async function findPullRequestStackForGuard( + number: number, + signal?: AbortSignal +): Promise { + try { + return await findPullRequestStack(number, signal); + } catch (error) { + if (isGitHubStackApiUnavailable(error)) return undefined; + throw error; + } +} + +function parsePullRequestNumberRows(value: unknown): number[] { + if (!Array.isArray(value) || value.length > 2) { + throw new TypeError("GitHub returned an invalid dependent pull request list"); + } + const rows: unknown[] = value; + return rows.map((row) => { + if ( + !isRecord(row) || + typeof row.number !== "number" || + !Number.isSafeInteger(row.number) || + row.number <= 0 + ) { + throw new TypeError("GitHub returned an invalid dependent pull request"); + } + return row.number; + }); +} + +/** + * Prevents ordinary single-PR mutations from breaking a native or candidate stack. + * @param pullRequest Pull request being mutated. + * @param action User-facing action description. + * @param signal Signal used to cancel the operation. + */ +async function requireStandalonePullRequest( + pullRequest: PullRequestSummary, + action: string, + signal?: AbortSignal +): Promise { + const stack = await findPullRequestStackForGuard(pullRequest.number, signal); + if (stack) { + throw Object.assign( + new Error( + `PR #${pullRequest.number} belongs to GitHub stack #${stack.number}. Use the stack-aware ${action} flow` + ), + { statusCode: 409 } + ); + } + if ( + pullRequest.isCrossRepository === true || + pullRequest.headRefName === DEFAULT_BASE + ) { + return; + } + + const dependentPullRequestNumbers = await runGhJson( + [ + "pr", + "list", + "--repo", + DASHBOARD_REPO, + "--state", + "open", + "--base", + pullRequest.headRefName, + "--limit", + "2", + "--json", + "number", + ], + parsePullRequestNumberRows, + signal + ); + if ( + dependentPullRequestNumbers.some( + (dependentPullRequestNumber) => + dependentPullRequestNumber !== pullRequest.number + ) + ) { + throw Object.assign( + new Error( + `PR #${pullRequest.number} has an open dependent pull request. Create or restructure the stack before ${action}` + ), + { statusCode: 409 } + ); + } +} + +/** + * Maps one native stack resource to the summary metadata for a member. + * @param stack Native GitHub stack. + * @param number Pull request number. + * @returns Dashboard stack metadata for the pull request. + */ +function pullRequestStackMetadata( + stack: GitHubPullRequestStackResource, + number: number +): PullRequestStack | undefined { + const index = stack.pull_requests.findIndex( + (pullRequest) => pullRequest.number === number + ); + if (index === -1) return undefined; + return { + baseRefName: stack.base.ref, + number: stack.number, + position: index + 1, + size: stack.pull_requests.length, + }; +} + /** * Appends one GitHub JSON-lines output row after size and blank-line validation. * @param line Line value. @@ -1362,19 +1900,36 @@ async function runGhJsonLines( } /** - * Lists open pull requests targeting the dashboard production branch. - * @returns Promise resolving to the list dashboard pull requests result. + * Lists open pull requests through GitHub GraphQL. + * @param includeStackMetadata Whether private-preview stack fields should be selected. + * @returns Raw pull request summaries. */ -export async function listDashboardPullRequests(): Promise { - if ( - process.env.NODE_ENV !== "production" && - process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && - !configuredGithubReadToken() - ) { - return listPublicDashboardPullRequests(); - } +async function listDashboardPullRequestGraphqlRows( + includeStackMetadata: boolean +): Promise { const repo = parseRepoParts(DASHBOARD_REPO); - const pullRequests = await runGhJsonLines( + const stackSelection = includeStackMetadata + ? ` + stack { + baseRefName + number + size + } + stackEntry { + position + }` + : ""; + const jqParts = [ + ".data.repository.pullRequests.nodes[]", + "| .statusCheckRollup = (if .statusCheckRollup.state then [{status: .statusCheckRollup.state}] else [] end)", + ]; + if (includeStackMetadata) { + jqParts.push( + "| .stack = (if (.stack and .stackEntry) then {baseRefName: .stack.baseRefName, number: .stack.number, position: .stackEntry.position, size: .stack.size} else null end)", + "| del(.stackEntry)" + ); + } + return runGhJsonLines( [ "api", "graphql", @@ -1390,7 +1945,6 @@ export async function listDashboardPullRequests(): Promise first: 100 after: $endCursor states: OPEN - baseRefName: "${DEFAULT_BASE}" orderBy: { field: UPDATED_AT, direction: DESC } ) { pageInfo { @@ -1404,6 +1958,7 @@ export async function listDashboardPullRequests(): Promise url headRefName headRefOid + isCrossRepository baseRefName author { login @@ -1414,6 +1969,7 @@ export async function listDashboardPullRequests(): Promise mergeable mergeStateStatus reviewDecision + ${stackSelection} latestOpinionatedReviews(first: 20) { nodes { state @@ -1432,16 +1988,95 @@ export async function listDashboardPullRequests(): Promise } } } - }`, + }`, "--jq", - [ - ".data.repository.pullRequests.nodes[]", - "| .statusCheckRollup = (if .statusCheckRollup.state then [{status: .statusCheckRollup.state}] else [] end)", - ].join(" "), + jqParts.join(" "), ], parsePullRequestSummary, { timeoutMs: PR_LIST_TIMEOUT_MS } ); +} + +function parseGraphqlPullRequestFieldNames(value: unknown): string[] { + if (typeof value !== "object" || value === null) { + throw new TypeError("GitHub GraphQL introspection response is invalid"); + } + const data = (value as Record).data; + const type = + typeof data === "object" && data !== null + ? (data as Record).__type + : undefined; + const fields = + typeof type === "object" && type !== null + ? (type as Record).fields + : undefined; + if (!Array.isArray(fields)) { + throw new TypeError("GitHub GraphQL introspection fields are invalid"); + } + return fields.map((field) => { + const name = + typeof field === "object" && field !== null + ? (field as Record).name + : undefined; + if (typeof name !== "string" || name.trim() === "") { + throw new TypeError("GitHub GraphQL introspection field name is invalid"); + } + return name; + }); +} + +async function supportsPullRequestStackGraphqlMetadata(): Promise { + const cacheKey = `${process.env.PATH ?? ""}\0${ + configuredGithubReadToken() ? "authenticated" : "anonymous" + }`; + const now = Date.now(); + if ( + stackGraphqlCapabilityCache?.key === cacheKey && + stackGraphqlCapabilityCache.expiresAt > now + ) { + return stackGraphqlCapabilityCache.result; + } + + const result = (async () => { + try { + const fieldNames = await runGhJson( + [ + "api", + "graphql", + "-f", + 'query=query { __type(name: "PullRequest") { fields { name } } }', + ], + parseGraphqlPullRequestFieldNames + ); + return fieldNames.includes("stack") && fieldNames.includes("stackEntry"); + } catch (error) { + logger.warn("github.stack_graphql_probe_failed", { error }); + return false; + } + })(); + stackGraphqlCapabilityCache = { + expiresAt: now + STACK_GRAPHQL_CAPABILITY_CACHE_MS, + key: cacheKey, + result, + }; + return result; +} + +/** + * Lists open pull requests for the dashboard repository. + * @returns Promise resolving to the list dashboard pull requests result. + */ +export async function listDashboardPullRequests(): Promise { + if ( + process.env.NODE_ENV !== "production" && + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && + !configuredGithubReadToken() + ) { + return listPublicDashboardPullRequests(); + } + const pullRequests = await listDashboardPullRequestGraphqlRows( + await supportsPullRequestStackGraphqlMetadata() + ); const refreshedPullRequests = await Promise.all( pullRequests.map(async (pr) => { @@ -1450,16 +2085,176 @@ export async function listDashboardPullRequests(): Promise } try { - return normalizePullRequest(await getPullRequest(pr.number)); + return normalizePullRequest({ + ...(await getPullRequest(pr.number)), + stack: pr.stack, + }); } catch { return normalizePullRequest(pr); } }) ); - return refreshedPullRequests.toSorted((a, b) => - b.updatedAt.localeCompare(a.updatedAt) + return applyPullRequestPreviewEligibility(refreshedPullRequests).toSorted((a, b) => + b.updatedAt.localeCompare(a.updatedAt) + ); +} + +/** + * Validates an ordered list of existing pull requests as one linear stack. + * @param numbers Pull request numbers ordered from bottom to top. + * @param pullRequests Current open pull requests. + * @returns The validated pull requests ordered from bottom to top. + */ +function validatePullRequestStackCandidate( + numbers: number[], + pullRequests: PullRequestSummary[] +): PullRequestSummary[] { + if (new Set(numbers).size !== numbers.length) { + throw Object.assign(new Error("A stack cannot contain duplicate pull requests"), { + statusCode: 400, + }); + } + + const pullRequestsByNumber = new Map( + pullRequests.map((pullRequest) => [pullRequest.number, pullRequest]) + ); + const orderedPullRequests = numbers.map((number) => { + const pullRequest = pullRequestsByNumber.get(number); + if (!pullRequest) { + throw Object.assign( + new Error(`PR #${number} is not an open pull request in this repository`), + { statusCode: 409 } + ); + } + if (pullRequest.stack) { + throw Object.assign( + new Error( + `PR #${number} already belongs to GitHub stack #${pullRequest.stack.number}` + ), + { statusCode: 409 } + ); + } + if (pullRequest.isCrossRepository === true) { + throw Object.assign( + new Error( + `PR #${number} is cross-repository and cannot join a GitHub stack` + ), + { statusCode: 409 } + ); + } + return pullRequest; + }); + + const bottomPullRequest = orderedPullRequests[0]; + if (!bottomPullRequest || bottomPullRequest.baseRefName !== DEFAULT_BASE) { + throw Object.assign( + new Error(`The bottom pull request must target ${DEFAULT_BASE}`), + { statusCode: 409 } + ); + } + + for (let index = 1; index < orderedPullRequests.length; index += 1) { + const previousPullRequest = orderedPullRequests[index - 1]; + const pullRequest = orderedPullRequests[index]; + if ( + !previousPullRequest || + !pullRequest || + pullRequest.baseRefName !== previousPullRequest.headRefName + ) { + throw Object.assign( + new Error( + `PR #${pullRequest?.number ?? numbers[index]} must target ${ + previousPullRequest?.headRefName ?? "the branch below it" + }` + ), + { statusCode: 409 } + ); + } + } + + const candidatePullRequests = pullRequests.filter( + (pullRequest) => + pullRequest.stack === undefined && pullRequest.isCrossRepository !== true + ); + const childrenByBase = new Map(); + for (const pullRequest of candidatePullRequests) { + const children = childrenByBase.get(pullRequest.baseRefName) ?? []; + children.push(pullRequest); + childrenByBase.set(pullRequest.baseRefName, children); + } + + for (const [index, pullRequest] of orderedPullRequests.entries()) { + const expectedChild = orderedPullRequests[index + 1]; + const children = childrenByBase.get(pullRequest.headRefName) ?? []; + if (children.length > 1) { + throw Object.assign( + new Error( + `PR #${pullRequest.number} has multiple open dependent pull requests; only a complete linear chain can become a GitHub stack` + ), + { statusCode: 409 } + ); + } + const child = children[0]; + if (expectedChild && child?.number !== expectedChild.number) { + throw Object.assign( + new Error( + `PR #${expectedChild.number} is not the current dependent of PR #${pullRequest.number}` + ), + { statusCode: 409 } + ); + } + if (!expectedChild && child) { + throw Object.assign( + new Error( + `PR #${child.number} depends on PR #${pullRequest.number} and must be included in the GitHub stack` + ), + { statusCode: 409 } + ); + } + } + + return orderedPullRequests; +} + +/** + * Creates a native GitHub stack from existing linear pull requests. + * @param numbers Pull request numbers ordered from bottom to top. + * @param signal Signal used to cancel the operation. + * @returns Pull request action response. + */ +export async function createPullRequestStack(numbers: number[], signal?: AbortSignal) { + const pullRequests = validatePullRequestStackCandidate( + numbers, + await listDashboardPullRequests() ); + const endpoint = pullRequestStacksEndpoint(); + const arguments_ = ["api", "-X", "POST", endpoint]; + for (const pullRequest of pullRequests) { + arguments_.push("-F", `pull_requests[]=${pullRequest.number}`); + } + arguments_.push("--include"); + let stack: GitHubPullRequestStackResource; + try { + stack = await runGhRestJson( + arguments_, + endpoint, + parseGitHubPullRequestStackResource, + signal + ); + } catch (error) { + if (isGitHubStackApiUnavailable(error)) { + throw Object.assign( + new Error("GitHub stacks are not enabled for this repository or token"), + { statusCode: 409 } + ); + } + throw error; + } + return { + isOk: true, + message: `GitHub stack #${stack.number} created with ${stack.pull_requests.length} PRs`, + }; } /** @@ -1503,6 +2298,7 @@ async function getPullRequest( "url", "headRefName", "headRefOid", + "isCrossRepository", "baseRefName", "author", "createdAt", @@ -1524,6 +2320,25 @@ async function getPullRequest( ); } +async function getPullRequestState( + number: number, + signal?: AbortSignal +): Promise { + return runGhJson( + [ + "pr", + "view", + String(number), + "--repo", + DASHBOARD_REPO, + "--json", + "state,headRefOid", + ], + parseGitHubPullRequestState, + signal + ); +} + /** * Checks the PR lifecycle without filtering by its current base branch. * @param number Number value. @@ -1534,11 +2349,7 @@ export async function isDashboardPullRequestOpen( number: number, signal?: AbortSignal ): Promise { - const result = await runGhJson( - ["pr", "view", String(number), "--repo", DASHBOARD_REPO, "--json", "state"], - parseGitHubPullRequestState, - signal - ); + const result = await getPullRequestState(number, signal); return result.state === "OPEN"; } @@ -1700,6 +2511,34 @@ function validateDashboardPr(pr: PullRequestSummary): void { } } +/** Validates native stack base and membership without imposing merge-only gates. */ +function validateDashboardStackMembership( + pr: PullRequestSummary, + stack: GitHubPullRequestStackResource +): void { + if (stack.base.ref !== DEFAULT_BASE) { + throw new Error( + `Only ${DEFAULT_BASE}-targeted pull request stacks can be managed here` + ); + } + if (!stack.pull_requests.some((pullRequest) => pullRequest.number === pr.number)) { + throw new Error( + `PR #${pr.number} is not a member of GitHub stack #${stack.number}` + ); + } +} + +/** Validates a native stacked pull request can be managed from the dashboard. */ +function validateDashboardStackPr( + pr: PullRequestSummary, + stack: GitHubPullRequestStackResource +): void { + validateDashboardStackMembership(pr, stack); + if (pr.isDraft) { + throw new Error("Draft pull requests cannot be approved from the dashboard"); + } +} + /** Validates a pull request can be updated with the latest base branch. */ function validateDashboardPrForBranchUpdate(pr: PullRequestSummary): void { if (pr.baseRefName !== DEFAULT_BASE) { @@ -1728,9 +2567,35 @@ function validateDashboardPrForApproval(pr: PullRequestSummary): void { } } +/** Validates a native stacked pull request can be approved and merged. */ +function validateDashboardStackPrForApproval( + pr: PullRequestSummary, + stack: GitHubPullRequestStackResource +): void { + validateDashboardStackPr(pr, stack); + if (!hasPullRequestChecksPassed(pr.statusCheckRollup)) { + throw new Error("Pull request CI checks must pass before approval"); + } + if (!isPullRequestReviewApproved(pr)) { + throw new Error("Pull request review approval is required before merging"); + } +} + /** Validates a pull request can receive Rajohan's review approval. */ -function validateDashboardPrForReviewApproval(pr: PullRequestSummary): void { - validateDashboardPr(pr); +function validateDashboardPrForReviewApproval( + pr: PullRequestSummary, + stack?: GitHubPullRequestStackResource, + isStackCandidate = false +): void { + if (stack) { + validateDashboardStackPr(pr, stack); + } else if (isStackCandidate) { + if (pr.isDraft) { + throw new Error("Draft pull requests cannot be approved from the dashboard"); + } + } else { + validateDashboardPr(pr); + } if (pr.author?.login === DEFAULT_REVIEWER_AUTHOR) { throw new Error("Rajohan cannot approve his own pull request"); } @@ -3104,11 +3969,165 @@ export async function prepareAndStartRollback( } } +/** + * Waits for GitHub's asynchronous stack merge to reach a terminal state. + * @param number Pull request selected as the top of the merge group. + * @param expectedHeadSha Expected selected pull request head. + * @param signal Signal used to cancel the operation. + * @returns Terminal asynchronous merge result. + */ +async function mergePullRequestStack( + number: number, + expectedHeadSha: string, + signal?: AbortSignal +): Promise { + if (!FULL_COMMIT_SHA_PATTERN.test(expectedHeadSha)) { + throw Object.assign( + new TypeError("Stack merge requires a full lowercase pull request head SHA"), + { statusCode: 400 } + ); + } + const repo = parseRepoParts(DASHBOARD_REPO); + // Keep both the local refetch and GitHub's request-side SHA precondition: + // neither a stale Delivery page nor a push in the final request window may + // merge a different selected head than the one the user confirmed. + // A command failure is intentionally not reconciled from PR state alone: + // without a successful response, Delivery cannot attribute an external + // merge to this exact-head request and must retain worktrees/deploy state. + let result = await runGhJsonWithResultBody( + [ + "api", + "-X", + "PUT", + `repos/${repo.owner}/${repo.name}/pulls/${number}/merge-async`, + "-F", + "merge_method=squash", + "-F", + "merge_action=default", + "-f", + `sha=${expectedHeadSha}`, + ], + parseGitHubAsyncPullRequestMergeResult, + signal, + STACK_MERGE_TIMEOUT_MS + ); + if ( + result.details.expected_head_sha && + result.details.expected_head_sha !== expectedHeadSha + ) { + throw Object.assign( + new Error( + `PR #${number} changed while GitHub accepted the stack merge. Verify the stack state before retrying` + ), + { statusCode: 409 } + ); + } + if ( + result.status === "pending" && + (result.details.expected_head_sha !== expectedHeadSha || + result.details.merge_action !== "default" || + result.details.merge_method !== "squash") + ) { + throw Object.assign( + new Error( + `PR #${number} already has an incompatible pending stack merge request` + ), + { statusCode: 409 } + ); + } + + const deadline = Date.now() + STACK_MERGE_TIMEOUT_MS; + while (result.status === "pending") { + const uuid = result.details.uuid; + if (!uuid) { + throw new Error("GitHub stack merge returned pending without a result id"); + } + const remainingBeforePoll = deadline - Date.now(); + if (remainingBeforePoll <= 0) { + throw new Error(`GitHub stack merge for PR #${number} timed out`); + } + signal?.throwIfAborted(); + await Bun.sleep(Math.min(STACK_MERGE_POLL_INTERVAL_MS, remainingBeforePoll)); + signal?.throwIfAborted(); + const remainingRequestTime = deadline - Date.now(); + if (remainingRequestTime <= 0) { + throw new Error(`GitHub stack merge for PR #${number} timed out`); + } + result = await runGhJson( + [ + "api", + `repos/${repo.owner}/${repo.name}/pulls/${number}/merge-async/${uuid}`, + ], + parseGitHubAsyncPullRequestMergeResult, + signal, + Math.min(60_000, remainingRequestTime) + ); + } + + if (result.status === "failed") { + throw Object.assign(new Error(result.details.message), { statusCode: 409 }); + } + return result; +} + interface PullRequestApprovalExecutionOptions { + expectedHeadSha: string; + expectedStackHeads?: PullRequestExpectedHead[]; lockHeldBy?: string; + mergeStack?: boolean; signal?: AbortSignal; } +function requireExpectedStackHeads( + value: unknown, + mergeStack: boolean +): PullRequestExpectedHead[] | undefined { + if (!mergeStack) { + if (value !== undefined) { + throw Object.assign( + new TypeError( + "Expected stack heads are valid only for a native stack merge" + ), + { statusCode: 400 } + ); + } + return undefined; + } + if (!Array.isArray(value) || value.length === 0 || value.length > 100) { + throw Object.assign( + new TypeError( + "Native stack merge requires the expected head of every included pull request" + ), + { statusCode: 400 } + ); + } + + const seenNumbers = new Set(); + const entries: unknown[] = value; + return entries.map((entry) => { + if ( + !isRecord(entry) || + Object.keys(entry).some((key) => key !== "headSha" && key !== "number") || + typeof entry.headSha !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(entry.headSha) || + typeof entry.number !== "number" || + !Number.isSafeInteger(entry.number) || + entry.number <= 0 || + seenNumbers.has(entry.number) + ) { + throw Object.assign( + new TypeError("Expected native stack pull request heads are invalid"), + { statusCode: 400 } + ); + } + seenNumbers.add(entry.number); + return { + headSha: entry.headSha, + number: entry.number, + }; + }); +} + /** * Performs approve pull request. * @param number Number value. @@ -3119,16 +4138,25 @@ interface PullRequestApprovalExecutionOptions { export async function approvePullRequest( number: number, willDeploy: boolean, - options: PullRequestApprovalExecutionOptions = {} + options: PullRequestApprovalExecutionOptions ) { + const expectedStackHeads = requireExpectedStackHeads( + options.expectedStackHeads, + options.mergeStack === true + ); const lockId = options.lockHeldBy ?? `approve-${Bun.randomUUIDv7()}`; let isReleaseLock = options.lockHeldBy !== undefined; let syncError: string | undefined; let deployError: string | undefined; let deployment: DeploymentJob | undefined; - let cleanup: WorktreeCleanupResult; - let previewCleanup: PullRequestPreviewCleanupResult; + let cleanup: WorktreeCleanupResult | undefined; + let cleanups: WorktreeCleanupResult[] | undefined; + let previewCleanup: PullRequestPreviewCleanupResult | undefined; + let previewCleanups: PullRequestPreviewCleanupResult[] | undefined; + let stack: GitHubPullRequestStackResource | undefined; + let stackPullRequests: GitHubPullRequestStackResource["pull_requests"] = []; + let mergeStatus: "enqueued" | "merged" | undefined; try { if (options.lockHeldBy) { @@ -3136,14 +4164,212 @@ export async function approvePullRequest( } await ensureProductionCheckout(options.signal); const pr = await getPullRequest(number, options.signal); - validateDashboardPrForApproval(pr); + if ( + !FULL_COMMIT_SHA_PATTERN.test(options.expectedHeadSha) || + pr.headRefOid !== options.expectedHeadSha + ) { + throw Object.assign( + new Error( + `PR #${number} changed after the Delivery page loaded. Refresh before merging` + ), + { statusCode: 409 } + ); + } + if (options.mergeStack) { + stack = await requirePullRequestStack(number, options.signal); + validateDashboardStackPr(pr, stack); + const selectedIndex = stack.pull_requests.findIndex( + (pullRequest) => pullRequest.number === number + ); + if (selectedIndex === -1) { + throw Object.assign( + new Error(`PR #${number} is not in GitHub stack #${stack.number}`), + { statusCode: 409 } + ); + } + const selectedPullRequest = stack.pull_requests[selectedIndex]; + if (!selectedPullRequest) { + throw new Error( + `GitHub stack #${stack.number} has no entry at position ${selectedIndex + 1}` + ); + } + if ( + selectedPullRequest.state !== "open" || + selectedPullRequest.merged_at !== null + ) { + throw Object.assign( + new Error(`PR #${number} is not open for stack merge`), + { statusCode: 409 } + ); + } + if (selectedPullRequest.head.sha !== options.expectedHeadSha) { + throw Object.assign( + new Error( + `PR #${number} changed after the Delivery page loaded. Refresh before merging the stack` + ), + { statusCode: 409 } + ); + } + stackPullRequests = []; + for (const pullRequest of stack.pull_requests.slice(0, selectedIndex + 1)) { + if (pullRequest.merged_at !== null) continue; + if (pullRequest.state !== "open") { + throw Object.assign( + new Error( + `PR #${pullRequest.number} is closed and blocks merging through PR #${number}` + ), + { statusCode: 409 } + ); + } + if (pullRequest.draft) { + throw Object.assign( + new Error( + `PR #${pullRequest.number} is a draft and blocks merging through PR #${number}` + ), + { statusCode: 409 } + ); + } + stackPullRequests.push(pullRequest); + } + if (expectedStackHeads?.length !== stackPullRequests.length) { + throw Object.assign( + new Error( + `GitHub stack #${stack.number} membership changed after the Delivery confirmation. Refresh before merging` + ), + { statusCode: 409 } + ); + } + for (const [index, stackPullRequest] of stackPullRequests.entries()) { + const expectedHead = expectedStackHeads[index]; + if (!expectedHead || expectedHead.number !== stackPullRequest.number) { + throw Object.assign( + new Error( + `GitHub stack #${stack.number} order changed after the Delivery confirmation. Refresh before merging` + ), + { statusCode: 409 } + ); + } + if (expectedHead.headSha !== stackPullRequest.head.sha) { + throw Object.assign( + new Error( + `PR #${stackPullRequest.number} changed after the Delivery confirmation. Refresh before merging the stack` + ), + { statusCode: 409 } + ); + } + } + const currentPullRequests = await Promise.all( + stackPullRequests.map((pullRequest) => + pullRequest.number === pr.number + ? Promise.resolve(pr) + : getPullRequest(pullRequest.number, options.signal) + ) + ); + for (const [index, currentPullRequest] of currentPullRequests.entries()) { + const stackPullRequest = stackPullRequests[index]; + const expectedHead = expectedStackHeads[index]; + if (!stackPullRequest || !expectedHead) continue; + if (currentPullRequest.headRefOid !== expectedHead.headSha) { + throw Object.assign( + new Error( + `PR #${currentPullRequest.number} changed after the Delivery confirmation. Refresh before merging the stack` + ), + { statusCode: 409 } + ); + } + try { + validateDashboardStackPrForApproval(currentPullRequest, stack); + } catch (error) { + throw Object.assign( + new Error( + `PR #${currentPullRequest.number}: ${errorMessage( + error, + "Stack member is not ready to merge" + )}` + ), + { statusCode: 409 } + ); + } + } + } else { + validateDashboardPrForApproval(pr); + await requireStandalonePullRequest(pr, "merge", options.signal); + } if (!options.lockHeldBy) { acquireDeploymentLock(lockId); isReleaseLock = true; } - await runCommand( - "gh", - [ + if (stack) { + const stackMergeResult = await mergePullRequestStack( + number, + options.expectedHeadSha, + options.signal + ); + if (stackMergeResult.status === "enqueued") { + mergeStatus = "enqueued"; + return { + isOk: true, + mergeStatus, + message: pullRequestStackQueuedMessage( + stack.number, + number, + stackPullRequests.length, + willDeploy + ), + }; + } + if (stackMergeResult.status !== "merged") { + throw new Error( + `GitHub stack merge returned unexpected status ${stackMergeResult.status}` + ); + } + const unconfirmedPullRequests: number[] = []; + for (const [index, pullRequest] of stackPullRequests.entries()) { + const state = await getPullRequestState( + pullRequest.number, + options.signal + ); + if ( + state.state !== "MERGED" || + state.headRefOid !== expectedStackHeads?.[index]?.headSha + ) { + unconfirmedPullRequests.push(pullRequest.number); + } + } + if (unconfirmedPullRequests.length > 0) { + logger.error("github.stack_merge_unconfirmed", { + affectedPullRequests: unconfirmedPullRequests, + number, + recoveryAction: + "Verify the GitHub stack state, then run syncMain before deploying", + stackNumber: stack.number, + worktreesRetained: true, + }); + throw Object.assign( + new Error( + `GitHub reported the stack merged, but ${unconfirmedPullRequests + .map((pullRequestNumber) => `PR #${pullRequestNumber}`) + .join( + ", " + )} did not confirm as merged. Worktrees were retained; verify GitHub, then run production sync before deploying` + ), + { statusCode: 409 } + ); + } + mergeStatus = "merged"; + cleanups = []; + previewCleanups = []; + for (const pullRequest of stackPullRequests) { + cleanups.push( + await cleanupPullRequestWorktree(pullRequest.head.ref, options.signal) + ); + // Stack merges and preview lifecycle actions share the exclusive worker. + previewCleanups.push( + await cleanupClosedPullRequestPreview(pullRequest.number) + ); + } + } else { + const mergeArguments = [ "pr", "merge", String(number), @@ -3151,13 +4377,18 @@ export async function approvePullRequest( "--delete-branch", "--repo", DASHBOARD_REPO, - ], - { signal: options.signal, timeoutMs: 120_000 } - ); - cleanup = await cleanupPullRequestWorktree(pr.headRefName, options.signal); - // The production entry point runs this inside the exclusive github.merge job, - // which shares the single-capacity worker with every preview lifecycle action. - previewCleanup = await cleanupClosedPullRequestPreview(number); + "--match-head-commit", + options.expectedHeadSha, + ]; + await runCommand("gh", mergeArguments, { + signal: options.signal, + timeoutMs: 120_000, + }); + cleanup = await cleanupPullRequestWorktree(pr.headRefName, options.signal); + // The production entry point runs this inside the exclusive github.merge job, + // which shares the single-capacity worker with every preview lifecycle action. + previewCleanup = await cleanupClosedPullRequestPreview(number); + } try { await syncMain(options.signal); @@ -3181,11 +4412,23 @@ export async function approvePullRequest( return { isOk: true, - message: pullRequestMergeMessage(number, willDeploy, syncError, deployError), + message: stack + ? pullRequestStackMergeMessage( + stack.number, + number, + stackPullRequests.length, + willDeploy, + syncError, + deployError + ) + : pullRequestMergeMessage(number, willDeploy, syncError, deployError), deployment, deployError, cleanup, + cleanups, + mergeStatus, previewCleanup, + previewCleanups, syncError, }; } @@ -3196,26 +4439,67 @@ function queuedPullRequestResult(execution: JobExecutionRecord): T { throw new Error("Pull request result was missing"); } +/** + * Creates a native GitHub stack through the persistent execution plane. + * @param pullRequests Pull request numbers ordered from bottom to top. + * @returns Promise resolving to the stack creation result. + */ +export async function runPullRequestStackCreation(pullRequests: number[]) { + const execution = enqueueJobExecution({ + actionKey: "github.stack-create", + displayName: `Create GitHub stack from ${pullRequests.length} PRs`, + payload: { pullRequests }, + resourceClass: "exclusive", + timeoutMs: 5 * 60 * 1000, + }); + return queuedPullRequestResult>>( + await waitForJobExecution(execution.id, { timeoutMs: 15 * 60 * 1000 }) + ); +} + /** * Runs PR merge/deploy through the shared persistent execution plane. * @param number Number value. * @param willDeploy Whether will deploy. + * @param options Exact-head and native stack merge options. * @returns Promise resolving to the run pull request approval result. */ -export async function runPullRequestApproval(number: number, willDeploy: boolean) { +export async function runPullRequestApproval( + number: number, + willDeploy: boolean, + options: { + expectedHeadSha: string; + expectedStackHeads?: PullRequestExpectedHead[]; + mergeStack?: boolean; + } +) { registerPullRequestJobLifecycleHandlers(); + const expectedStackHeads = requireExpectedStackHeads( + options.expectedStackHeads, + options.mergeStack === true + ); const deploymentLockId = `approve-${Bun.randomUUIDv7()}`; acquireDeploymentLock(deploymentLockId); + let timeoutMs = 10 * 60 * 1000; + if (options.mergeStack) timeoutMs = STACK_MERGE_JOB_TIMEOUT_MS; + if (willDeploy) timeoutMs = 45 * 60 * 1000; let execution: JobExecutionRecord; try { execution = enqueueJobExecution({ actionKey: willDeploy ? "github.merge-deploy" : "github.merge", displayName: willDeploy - ? `Merge and deploy PR #${number}` - : `Merge PR #${number}`, - payload: { deploymentLockId, number, willDeploy }, + ? `Merge and deploy ${options.mergeStack ? "stack through " : ""}PR #${number}` + : `Merge ${options.mergeStack ? "stack through " : ""}PR #${number}`, + payload: { + deploymentLockId, + expectedHeadSha: options.expectedHeadSha, + expectedStackHeads, + mergeStack: options.mergeStack === true, + number, + willDeploy, + }, resourceClass: "exclusive", - timeoutMs: (willDeploy ? 45 : 10) * 60 * 1000, + timeoutMs, }); } catch (error) { releaseDeploymentLock(deploymentLockId); @@ -3234,7 +4518,31 @@ export async function runPullRequestApproval(number: number, willDeploy: boolean */ export async function approvePullRequestReview(number: number, signal?: AbortSignal) { const pr = await getPullRequest(number, signal); - validateDashboardPrForReviewApproval(pr); + let stack: GitHubPullRequestStackResource | undefined; + let stackCandidatePullRequests: PullRequestSummary[] | undefined; + if (pr.baseRefName !== DEFAULT_BASE) { + stack = await findPullRequestStackForGuard(number, signal); + if (!stack) { + const pullRequests = await listDashboardPullRequests(); + stackCandidatePullRequests = [ + ...pullRequests.filter((pullRequest) => pullRequest.number !== number), + pr, + ]; + if (!pullRequestPreviewScope(pr, stackCandidatePullRequests)) { + throw Object.assign( + new Error( + `PR #${number} is not part of a main-rooted linear candidate or GitHub stack` + ), + { statusCode: 409 } + ); + } + } + } + validateDashboardPrForReviewApproval( + pr, + stack, + stackCandidatePullRequests !== undefined + ); await runCommand( "gh", @@ -3246,12 +4554,33 @@ export async function approvePullRequestReview(number: number, signal?: AbortSig } ); - const pullRequest = await getPullRequest(number, signal); + const refreshedPullRequest = await getPullRequest(number, signal); + const stackMetadata = stack ? pullRequestStackMetadata(stack, number) : undefined; + const pullRequest = normalizePullRequest({ + ...refreshedPullRequest, + stack: stackMetadata, + }); + const eligibilityPeers = stack + ? await listDashboardPullRequests() + : stackCandidatePullRequests; + const pullRequestsWithEligibility = applyPullRequestPreviewEligibility( + eligibilityPeers + ? [ + ...eligibilityPeers.filter( + (candidate) => candidate.number !== pullRequest.number + ), + pullRequest, + ] + : [pullRequest] + ); return { isOk: true, message: `PR #${number} review approved`, - pullRequest, + pullRequest: + pullRequestsWithEligibility.find( + (candidate) => candidate.number === pullRequest.number + ) ?? pullRequest, }; } @@ -3264,6 +4593,7 @@ export async function approvePullRequestReview(number: number, signal?: AbortSig export async function updatePullRequestBranch(number: number, signal?: AbortSignal) { const pr = await getPullRequest(number, signal); validateDashboardPrForBranchUpdate(pr); + await requireStandalonePullRequest(pr, "branch update", signal); const repo = parseRepoParts(DASHBOARD_REPO); const arguments_ = [ "api", @@ -3280,7 +4610,9 @@ export async function updatePullRequestBranch(number: number, signal?: AbortSign return { isOk: true, message: `PR #${number} branch update started`, - pullRequest: await getPullRequest(number, signal), + pullRequest: applyPullRequestPreviewEligibility([ + await getPullRequest(number, signal), + ])[0], }; } @@ -3298,6 +4630,7 @@ export async function rejectPullRequest( ) { const pr = await getPullRequest(number, signal); validateDashboardPr(pr); + await requireStandalonePullRequest(pr, "reject", signal); await runCommand( "gh", @@ -3380,6 +4713,29 @@ function executionPullRequestNumber(payload: Record): number { return validatePrNumber(number); } +function executionPullRequestStackNumbers(payload: Record): number[] { + const pullRequests = payload.pullRequests; + if ( + !Array.isArray(pullRequests) || + pullRequests.length < 2 || + pullRequests.length > 100 + ) { + throw Object.assign(new Error("Pull request stack payload is invalid"), { + statusCode: 400, + }); + } + const numbers: number[] = []; + for (const value of pullRequests) { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw Object.assign(new Error("Pull request stack payload is invalid"), { + statusCode: 400, + }); + } + numbers.push(value); + } + return numbers; +} + async function executePullRequestMerge( job: ScheduledJob, signal: AbortSignal | undefined, @@ -3388,6 +4744,20 @@ async function executePullRequestMerge( context.protectFromCancellation(); const number = executionPullRequestNumber(job.actionPayload); const willDeploy = job.actionPayload.willDeploy === true; + const mergeStack = job.actionPayload.mergeStack === true; + const expectedHeadSha = job.actionPayload.expectedHeadSha; + const expectedStackHeads = requireExpectedStackHeads( + job.actionPayload.expectedStackHeads, + mergeStack + ); + if ( + typeof expectedHeadSha !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(expectedHeadSha) + ) { + throw Object.assign(new Error("Expected pull request head SHA is invalid"), { + statusCode: 400, + }); + } const deploymentLockId = job.actionPayload.deploymentLockId; if ( deploymentLockId !== undefined && @@ -3398,7 +4768,10 @@ async function executePullRequestMerge( }); } const result = await approvePullRequest(number, willDeploy, { + expectedHeadSha, + expectedStackHeads, lockHeldBy: deploymentLockId, + mergeStack, signal, }); const message = result.syncError || result.deployError; @@ -3471,6 +4844,13 @@ export function registerPullRequestExecutionActions(): void { }); registerScheduledJobAction("github.merge", executePullRequestMerge); registerScheduledJobAction("github.merge-deploy", executePullRequestMerge); + registerScheduledJobAction("github.stack-create", async (job, signal, context) => { + const pullRequests = executionPullRequestStackNumbers(job.actionPayload); + context.protectFromCancellation(); + return { + result: await createPullRequestStack(pullRequests, signal), + }; + }); registerScheduledJobAction("github.review-approval", async (job, signal, context) => { const number = executionPullRequestNumber(job.actionPayload); context.protectFromCancellation(); diff --git a/backend/test/pullRequestPreview.test.ts b/backend/test/pullRequestPreview.test.ts index 69bf80cc6..67f9cbeca 100644 --- a/backend/test/pullRequestPreview.test.ts +++ b/backend/test/pullRequestPreview.test.ts @@ -49,6 +49,7 @@ import type { } from "../src/services/scheduledJobs.ts"; import * as scheduledJobs from "../src/services/scheduledJobs.ts"; import { apiErrorExpectation } from "./support/apiErrorExpectation.ts"; +import { captureRejection } from "./support/rejections.ts"; import { captureStructuredLogs } from "./support/structuredLogCapture.ts"; const COMMIT = "a".repeat(40); @@ -63,9 +64,17 @@ function readJsonRecord(filePath: string): Record { return value; } -function previewRouteRequest(number: string) { +function previewRouteRequest(number: string, expectedHeadSha?: string) { return Object.assign( new Request(`https://dashboard.test/api/pull-requests/${number}/preview`, { + body: + expectedHeadSha === undefined + ? undefined + : JSON.stringify({ expectedHeadSha }), + headers: + expectedHeadSha === undefined + ? undefined + : { "Content-Type": "application/json" }, method: "POST", }), { params: { number } } @@ -179,19 +188,19 @@ describe("managed pull request preview", () => { process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = "1"; try { - expect(getDeliveryPullRequestPreviewStatus()).resolves.toEqual({ + expect(await getDeliveryPullRequestPreviewStatus()).toEqual({ controlsAvailable: false, message: "PR dev controls are available only from the production Dashboard.", status: "stopped", }); - expect(prepareAndStartPullRequestPreview(342)).resolves.toEqual({ + expect(await prepareAndStartPullRequestPreview(342, COMMIT)).toEqual({ controlsAvailable: false, message: "PR dev controls are available only from the production Dashboard.", status: "stopped", }); - expect(prepareAndStopPullRequestPreview(342)).resolves.toEqual({ + expect(await prepareAndStopPullRequestPreview(342)).toEqual({ controlsAvailable: false, message: "PR dev controls are available only from the production Dashboard.", @@ -721,10 +730,10 @@ describe("managed pull request preview", () => { try { const candidate = { - authorLogin: "mira-2026", - baseRefName: "main", + authorLogins: ["mira-2026"], commitSha: COMMIT, number: 335, + rootBaseRefName: "main", title: "Trusted preview", }; const running = await startPullRequestPreview(candidate, { @@ -1116,25 +1125,32 @@ describe("managed pull request preview", () => { }; try { - expect(prepareAndStartPullRequestPreview(335)).resolves.toMatchObject({ + expect(await prepareAndStartPullRequestPreview(335, COMMIT)).toMatchObject({ commitSha: COMMIT, number: 335, status: "starting", title: "Trusted preview", updatedAt: expect.any(String), }); + expect( + await captureRejection(() => + prepareAndStartPullRequestPreview(335, "b".repeat(40)) + ) + ).toMatchObject({ statusCode: 409 }); statusSpy.mockResolvedValueOnce({ number: 334, status: "running", }); - expect(prepareAndStartPullRequestPreview(335)).rejects.toMatchObject({ - statusCode: 409, - }); - expect(prepareAndStopPullRequestPreview(335)).resolves.toEqual({ + expect( + await captureRejection(() => + prepareAndStartPullRequestPreview(335, COMMIT) + ) + ).toMatchObject({ statusCode: 409 }); + expect(await prepareAndStopPullRequestPreview(335)).toEqual({ number: 335, status: "stopped", }); - expect(prepareAndStopPullRequestPreview()).resolves.toEqual({ + expect(await prepareAndStopPullRequestPreview()).toEqual({ number: 335, status: "stopped", }); @@ -1169,7 +1185,7 @@ describe("managed pull request preview", () => { await import("../src/routes/pullRequestRoutes.ts"); const startResponse = await pullRequestRoutes[ "/api/pull-requests/:number/preview/start" - ].POST(previewRouteRequest("335")); + ].POST(previewRouteRequest("335", COMMIT)); expect(startResponse.status).toBe(202); expect(startResponse.json()).resolves.toMatchObject({ isOk: true, @@ -1181,6 +1197,22 @@ describe("managed pull request preview", () => { updatedAt: expect.any(String), }, }); + const missingHeadResponse = await pullRequestRoutes[ + "/api/pull-requests/:number/preview/start" + ].POST(previewRouteRequest("335", "")); + expect(missingHeadResponse.status).toBe(400); + expect(await missingHeadResponse.json()).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { + path: "body.expectedHeadSha", + }, + ], + }, + }, + }); const stopResponse = await pullRequestRoutes[ "/api/pull-requests/:number/preview/stop" @@ -1262,7 +1294,7 @@ describe("managed pull request preview", () => { "/api/pull-requests/:number/preview/stop", ] as const) { const invalidResponse = await pullRequestRoutes[route].POST( - previewRouteRequest("invalid") + previewRouteRequest("invalid", COMMIT) ); expect(invalidResponse.status).toBe(400); expect(invalidResponse.json()).resolves.toEqual( @@ -1277,7 +1309,7 @@ describe("managed pull request preview", () => { ); const failedStartResponse = await pullRequestRoutes[ "/api/pull-requests/:number/preview/start" - ].POST(previewRouteRequest("335")); + ].POST(previewRouteRequest("335", COMMIT)); expect(failedStartResponse.status).toBe(503); expect(failedStartResponse.json()).resolves.toEqual( apiErrorExpectation("preview startup unavailable") @@ -1329,9 +1361,10 @@ describe("managed pull request preview", () => { }); expect(startSpy).toHaveBeenCalledWith( expect.objectContaining({ - authorLogin: "mira-2026", + authorLogins: ["mira-2026"], commitSha: COMMIT, number: 335, + rootBaseRefName: "main", }), expect.objectContaining({ protectFromCancellation: expect.any(Function), @@ -1422,29 +1455,38 @@ describe("managed pull request preview", () => { status: "stopped", }); expect( - startPullRequestPreview( - { - authorLogin: "external", - baseRefName: "main", - commitSha: COMMIT, - number: 335, - title: "Untrusted PR", - }, - { config } + await captureRejection(() => + startPullRequestPreview( + { + authorLogins: ["mira-2026", "external"], + commitSha: COMMIT, + number: 335, + rootBaseRefName: "main", + title: "Untrusted PR", + }, + { config } + ) ) - ).rejects.toThrow("Pull request author is not allowed to run host previews"); + ).toMatchObject({ + message: + "Every pull request included in a host preview must have an allowed author", + }); expect( - startPullRequestPreview( - { - authorLogin: "mira-2026", - baseRefName: "release", - commitSha: COMMIT, - number: 335, - title: "Wrong base", - }, - { config } + await captureRejection(() => + startPullRequestPreview( + { + authorLogins: ["mira-2026"], + commitSha: COMMIT, + number: 335, + rootBaseRefName: "release", + title: "Wrong base", + }, + { config } + ) ) - ).rejects.toThrow("Only main-targeted pull requests can be previewed"); + ).toMatchObject({ + message: "Only main-rooted pull requests can be previewed", + }); } finally { rmSync(root, { force: true, recursive: true }); } diff --git a/backend/test/routeAndServiceBehavior.test.ts b/backend/test/routeAndServiceBehavior.test.ts index d806dd723..2ceab34dd 100644 --- a/backend/test/routeAndServiceBehavior.test.ts +++ b/backend/test/routeAndServiceBehavior.test.ts @@ -3268,6 +3268,17 @@ describe("backend route and service behavior", () => { ); expect(response.status).toBe(400); } + + const invalidPullRequestStack = await pullRequestRoutes[ + "/api/pull-requests/stacks" + ].POST( + new Request("https://test.local/api/pull-requests/stacks", { + body: JSON.stringify({ pullRequests: [1] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ); + expect(invalidPullRequestStack.status).toBe(400); }); it("aggregates metrics tokens by model, display label, and session type", async () => { diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 2bf90b464..4f76d8373 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -16,6 +16,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; +import type { PullRequestSummary } from "../../contracts/delivery.ts"; import { parseGitWorkspaceSummary } from "../../contracts/git.ts"; import { parseJsonText, requestUrl } from "../../test/support/fetch.ts"; import type { DashboardSocket } from "../src/dashboardSocket.ts"; @@ -32,6 +33,7 @@ import { } from "../src/releaseManager.ts"; import { CONFIG_REDACTION_SENTINEL } from "../src/services/configRedaction.ts"; import { apiErrorExpectation } from "./support/apiErrorExpectation.ts"; +import { captureRejection } from "./support/rejections.ts"; import { createReleaseFixture, rewriteReleaseFixtureSchemaVersion, @@ -201,10 +203,18 @@ function writeFakeGh(binaryPath: string): void { binaryPath, String.raw`#!/usr/bin/env bash set -euo pipefail -if [[ "$1" == "api" && "$2" == "graphql" && "$*" == *"--paginate"* && "$*" == *"-F owner=rajohan"* && "$*" == *"-F name=Mira-Dashboard"* && "$*" == *"-f query="* && "$*" == *"--jq"* ]]; then - printf '%s\n' '{"number":1,"title":"Ready PR","body":"","url":"https://github.test/pr/1","headRefName":"ready","headRefOid":"head1","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T08:00:00.000Z","updatedAt":"2026-06-24T09:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"latestOpinionatedReviews":{"nodes":[{"state":"APPROVED","submittedAt":"2026-06-24T08:30:00.000Z","author":{"login":"rajohan"}}]},"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T08:45:00.000Z"}]}' +if [[ "$1" == "api" && "$2" == "graphql" && "$*" != *"--paginate"* ]]; then + printf '%s\n' '{"data":{"__type":{"fields":[{"name":"stack"},{"name":"stackEntry"}]}}}' +elif [[ "$1" == "api" && "$2" == "graphql" && "$*" == *"--paginate"* && "$*" == *"-F owner=rajohan"* && "$*" == *"-F name=Mira-Dashboard"* && "$*" == *"-f query="* && "$*" == *"--jq"* ]]; then + if [[ "$*" == *'baseRefName: "main"'* ]]; then + echo "pull request list unexpectedly filtered to main" >&2 + exit 2 + fi + printf '%s\n' '{"number":1,"title":"Ready PR","body":"","url":"https://github.test/pr/1","headRefName":"ready","headRefOid":"head1","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T08:00:00.000Z","updatedAt":"2026-06-24T09:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"latestOpinionatedReviews":{"nodes":[{"state":"APPROVED","submittedAt":"2026-06-24T08:30:00.000Z","author":{"login":"rajohan"}}]},"additions":1,"deletions":0,"changedFiles":1,"stack":{"baseRefName":"main","number":42,"position":1,"size":2},"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T08:45:00.000Z"}]}' printf '%s\n' '{"number":2,"title":"Blocked cached PR","body":"","url":"https://github.test/pr/2","headRefName":"blocked","headRefOid":"head2","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T10:00:00.000Z","updatedAt":"2026-06-24T11:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"BLOCKED","reviewDecision":"APPROVED","latestOpinionatedReviews":{"nodes":[]},"additions":2,"deletions":1,"changedFiles":2,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T10:45:00.000Z"}]}' printf '%s\n' '{"number":3,"title":"Ghost-authored PR","body":"","url":"https://github.test/pr/3","headRefName":"ghost","headRefOid":"head3","baseRefName":"main","author":null,"createdAt":"2026-06-24T11:00:00.000Z","updatedAt":"2026-06-24T12:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"latestOpinionatedReviews":{"nodes":[{"state":"APPROVED","submittedAt":"2026-06-24T11:30:00.000Z","author":null}]},"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[]}' + printf '%s\n' '{"number":4,"title":"Stacked PR","body":"","url":"https://github.test/pr/4","headRefName":"stacked","headRefOid":"head4","baseRefName":"ready","author":{"login":"mira-2026"},"createdAt":"2026-06-24T12:00:00.000Z","updatedAt":"2026-06-24T13:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"latestOpinionatedReviews":{"nodes":[]},"additions":4,"deletions":1,"changedFiles":2,"stack":{"baseRefName":"main","number":42,"position":2,"size":2},"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T12:45:00.000Z"}]}' + printf '%s\n' '{"number":5,"title":"Fork PR","body":"","url":"https://github.test/pr/5","headRefName":"main","headRefOid":"head5","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T06:00:00.000Z","updatedAt":"2026-06-24T07:00:00.000Z","isCrossRepository":true,"isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"latestOpinionatedReviews":{"nodes":[]},"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T06:45:00.000Z"}]}' elif [[ "$1 $2 $3" == "pr view 2" && "$*" == *"--json state"* ]]; then printf '%s\n' '{"state":"OPEN"}' elif [[ "$1 $2 $3" == "pr view 99" && "$*" == *"--json state"* ]]; then @@ -220,12 +230,40 @@ fi chmodSync(binaryPath, 0o755); } +function writeFakeGhWithoutStackGraphqlFields( + binaryPath: string, + logPath: string, + probeFails = false +): void { + writeFileSync( + binaryPath, + String.raw`#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +if [[ "$1" == "api" && "$2" == "graphql" && "$*" != *"--paginate"* ]]; then + ${ + probeFails + ? "printf 'stack metadata probe unavailable\\n' >&2\n exit 2" + : `printf '%s\\n' '{"data":{"__type":{"fields":[{"name":"number"}]}}}'` + } +elif [[ "$1" == "api" && "$2" == "graphql" && "$*" == *"--paginate"* ]]; then + printf '%s\n' '{"number":31,"title":"Fallback PR","body":"","url":"https://github.test/pr/31","headRefName":"fallback","headRefOid":"head31","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-07-30T08:00:00.000Z","updatedAt":"2026-07-30T09:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"latestOpinionatedReviews":{"nodes":[]},"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[]}' +else + echo "unexpected gh args: $*" >&2 + exit 2 +fi +` + ); + chmodSync(binaryPath, 0o755); +} + function writeFakeGhForPullRequestActions(binaryPath: string, logPath: string): void { writeFileSync( binaryPath, String.raw`#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +set -- "$@" "" "" "" "" if [[ "$1 $2 $3" == "pr view 3" ]]; then printf '%s\n' '{"number":3,"title":"Needs review","body":"","url":"https://github.test/pr/3","headRefName":"review-branch","headRefOid":"head3","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T10:00:00.000Z","updatedAt":"2026-06-24T11:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"reviews":[],"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T11:00:00.000Z"}]}' elif [[ "$1 $2 $3" == "pr view 4" ]]; then @@ -238,6 +276,10 @@ elif [[ "$1 $2" == "api -X" && "$*" == *"repos/rajohan/Mira-Dashboard/pulls/4/up printf '{}\n' elif [[ "$1 $2 $3" == "pr close 5" ]]; then printf 'closed\n' +elif [[ "$1" == "api" && "$2" == repos/rajohan/Mira-Dashboard/stacks?pull_request=* ]]; then + printf '[]\n' +elif [[ "$1 $2" == "pr list" ]]; then + printf '[]\n' else echo "unexpected gh args: $*" >&2 exit 2 @@ -253,7 +295,7 @@ function writeFakeGhForPullRequestValidation(binaryPath: string): void { String.raw`#!/usr/bin/env bash set -euo pipefail if [[ "$1 $2 $3" == "pr view 6" ]]; then - printf '%s\n' '{"number":6,"title":"Draft","body":"","url":"https://github.test/pr/6","headRefName":"draft-branch","headRefOid":"head6","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T10:00:00.000Z","updatedAt":"2026-06-24T11:00:00.000Z","isDraft":true,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"reviews":[],"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T11:00:00.000Z"}]}' + printf '%s\n' '{"number":6,"title":"Draft","body":"","url":"https://github.test/pr/6","headRefName":"draft-branch","headRefOid":"6666666666666666666666666666666666666666","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T10:00:00.000Z","updatedAt":"2026-06-24T11:00:00.000Z","isDraft":true,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":null,"reviews":[],"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T11:00:00.000Z"}]}' elif [[ "$1 $2 $3" == "pr view 7" ]]; then printf '%s\n' '{"number":7,"title":"Wrong base","body":"","url":"https://github.test/pr/7","headRefName":"feature","headRefOid":"head7","baseRefName":"develop","author":{"login":"mira-2026"},"createdAt":"2026-06-24T10:00:00.000Z","updatedAt":"2026-06-24T11:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED","reviews":[],"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T11:00:00.000Z"}]}' elif [[ "$1 $2 $3" == "pr view 8" ]]; then @@ -271,16 +313,726 @@ fi chmodSync(binaryPath, 0o755); } -function writeFakeGhForPullRequestMerge(binaryPath: string, logPath: string): void { +function writeFakeGhForPullRequestMerge( + binaryPath: string, + logPath: string, + dependentPullRequestNumbers: number[] = [], + options: { + headRefName?: string; + isCrossRepository?: boolean; + } = {} +): void { + const headSha = "1".repeat(40); + const dependentPullRequestsJson = JSON.stringify( + dependentPullRequestNumbers.map((number) => ({ number })) + ); writeFileSync( binaryPath, String.raw`#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +set -- "$@" "" "" "" "" if [[ "$1 $2 $3" == "pr view 11" ]]; then - printf '%s\n' '{"number":11,"title":"Merge me","body":"","url":"https://github.test/pr/11","headRefName":"merge-branch","headRefOid":"head11","baseRefName":"main","author":{"login":"mira-2026"},"createdAt":"2026-06-24T10:00:00.000Z","updatedAt":"2026-06-24T11:00:00.000Z","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED","reviews":[],"additions":1,"deletions":0,"changedFiles":1,"statusCheckRollup":[{"name":"ci","conclusion":"success","completedAt":"2026-06-24T11:00:00.000Z"}]}' + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "main", + body: "", + changedFiles: 1, + createdAt: "2026-06-24T10:00:00.000Z", + deletions: 0, + headRefName: options.headRefName ?? "merge-branch", + headRefOid: headSha, + isCrossRepository: options.isCrossRepository ?? false, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 11, + reviewDecision: "APPROVED", + reviews: [], + statusCheckRollup: [ + { + completedAt: "2026-06-24T11:00:00.000Z", + conclusion: "success", + name: "ci", + }, + ], + title: "Merge me", + updatedAt: "2026-06-24T11:00:00.000Z", + url: "https://github.test/pr/11", + }) + )} elif [[ "$1 $2 $3" == "pr merge 11" ]]; then printf 'merged\n' +elif [[ "$1" == "api" && "$2" == "repos/rajohan/Mira-Dashboard/stacks?pull_request=11&per_page=2" ]]; then + printf '[]\n' +elif [[ "$1 $2" == "pr list" ]]; then + printf '%s\n' ${JSON.stringify(dependentPullRequestsJson)} +else + echo "unexpected gh args: $*" >&2 + exit 2 +fi +` + ); + chmodSync(binaryPath, 0o755); +} + +function writeFakeGhForPullRequestStackMerge( + binaryPath: string, + logPath: string, + status: + | "enqueued" + | "failed" + | "head-mismatch" + | "merged" + | "pending-merged" + | "pending-missing-id" + | "pending-options-mismatch" + | "request-error-merged", + targetNumber: 11 | 12 | 13 = 13, + options: { + changedHeadNumber?: 11 | 12 | 13; + closedNumber?: 11 | 12 | 13; + mismatchedConfirmedHeadNumber?: 11 | 12 | 13; + unconfirmedNumber?: 11 | 12 | 13; + } = {} +): void { + const pullRequestHeadShas = { + 11: "1".repeat(40), + 12: "2".repeat(40), + 13: "3".repeat(40), + }; + const currentPullRequestHeadShas = { ...pullRequestHeadShas }; + if (options.changedHeadNumber) { + currentPullRequestHeadShas[options.changedHeadNumber] = "9".repeat(40); + } + const confirmedPullRequestHeadShas = { ...currentPullRequestHeadShas }; + if (options.mismatchedConfirmedHeadNumber) { + confirmedPullRequestHeadShas[options.mismatchedConfirmedHeadNumber] = "8".repeat( + 40 + ); + } + const defaultPullRequestState = + status === "pending-missing-id" || status === "pending-options-mismatch" + ? "OPEN" + : "MERGED"; + let asyncResult: Record; + if (status.startsWith("pending")) { + asyncResult = { + details: { + expected_head_sha: pullRequestHeadShas[targetNumber], + merge_action: "default", + merge_method: status === "pending-options-mismatch" ? "merge" : "squash", + message: "Stack merge is pending.", + ...(status === "pending-merged" ? { uuid: "merge-uuid" } : {}), + }, + status: "pending", + }; + } else if (status === "head-mismatch") { + asyncResult = { + details: { + expected_head_sha: "9".repeat(40), + message: "The pull request head changed.", + }, + status: "merged", + }; + } else if (status === "merged") { + asyncResult = { + details: { + message: "Pull request was merged.", + sha: "a".repeat(40), + }, + status, + }; + } else { + asyncResult = { + details: { + message: + status === "enqueued" + ? "Pull request was added to the merge queue." + : "Required check failed.", + }, + status, + }; + } + const polledMergeResult = JSON.stringify({ + details: { + message: "Pull request was merged.", + sha: "a".repeat(40), + }, + status: "merged", + }); + writeFileSync( + binaryPath, + String.raw`#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +set -- "$@" "" "" "" "" +if [[ "$1 $2 $3" == "pr view 11" && "$*" == *"--json state"* ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + headRefOid: confirmedPullRequestHeadShas[11], + state: options.unconfirmedNumber === 11 ? "CLOSED" : defaultPullRequestState, + }) + )} +elif [[ "$1 $2 $3" == "pr view 12" && "$*" == *"--json state"* ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + headRefOid: confirmedPullRequestHeadShas[12], + state: options.unconfirmedNumber === 12 ? "CLOSED" : defaultPullRequestState, + }) + )} +elif [[ "$1 $2 $3" == "pr view 13" && "$*" == *"--json state"* ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + headRefOid: confirmedPullRequestHeadShas[13], + state: options.unconfirmedNumber === 13 ? "CLOSED" : defaultPullRequestState, + }) + )} +elif [[ "$1 $2 $3" == "pr view 11" ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "main", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:00:00.000Z", + deletions: 0, + headRefName: "stack-bottom", + headRefOid: currentPullRequestHeadShas[11], + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 11, + reviewDecision: "APPROVED", + reviews: [], + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Stack bottom", + updatedAt: "2026-07-30T11:00:00.000Z", + url: "https://github.test/pr/11", + }) + )} +elif [[ "$1 $2 $3" == "pr view 12" ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "stack-bottom", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:01:00.000Z", + deletions: 0, + headRefName: "stack-middle", + headRefOid: currentPullRequestHeadShas[12], + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 12, + reviewDecision: "APPROVED", + reviews: [], + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Stack middle", + updatedAt: "2026-07-30T11:01:00.000Z", + url: "https://github.test/pr/12", + }) + )} +elif [[ "$1 $2 $3" == "pr view 13" ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "stack-middle", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:02:00.000Z", + deletions: 0, + headRefName: "stack-top", + headRefOid: currentPullRequestHeadShas[13], + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 13, + reviewDecision: "APPROVED", + reviews: [], + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Stack top", + updatedAt: "2026-07-30T11:02:00.000Z", + url: "https://github.test/pr/13", + }) + )} +elif [[ "$1" == "api" && "$2" == ${JSON.stringify( + `repos/rajohan/Mira-Dashboard/stacks?pull_request=${targetNumber}&per_page=2` + )} ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify([ + { + base: { ref: "main" }, + created_at: "2026-07-30T10:05:00.000Z", + id: 360, + node_id: "S_stack360", + number: 360, + open: true, + pull_requests: [ + { + draft: false, + head: { + ref: "stack-bottom", + sha: currentPullRequestHeadShas[11], + }, + merged_at: null, + number: 11, + state: options.closedNumber === 11 ? "closed" : "open", + }, + { + draft: false, + head: { + ref: "stack-middle", + sha: currentPullRequestHeadShas[12], + }, + merged_at: null, + number: 12, + state: options.closedNumber === 12 ? "closed" : "open", + }, + { + draft: false, + head: { + ref: "stack-top", + sha: currentPullRequestHeadShas[13], + }, + merged_at: null, + number: 13, + state: options.closedNumber === 13 ? "closed" : "open", + }, + ], + url: "https://api.github.test/stacks/360", + }, + ]) + )} +elif [[ "$1 $2 $3" == "api -X PUT" && "$4" == ${JSON.stringify( + `repos/rajohan/Mira-Dashboard/pulls/${targetNumber}/merge-async` + )} ]]; then + ${ + status === "request-error-merged" + ? "echo 'request interrupted' >&2\n exit 1" + : `printf '%s\\n' ${JSON.stringify(JSON.stringify(asyncResult))} + ${status === "failed" ? "exit 1" : "exit 0"}` + } +elif [[ "$1" == "api" && "$2" == ${JSON.stringify( + `repos/rajohan/Mira-Dashboard/pulls/${targetNumber}/merge-async/merge-uuid` + )} ]]; then + printf '%s\n' ${JSON.stringify(polledMergeResult)} +else + echo "unexpected gh args: $*" >&2 + exit 2 +fi +` + ); + chmodSync(binaryPath, 0o755); +} + +function writeFakeGitForPullRequestStackMerge( + binaryPath: string, + repoRoot: string, + worktreeRoot: string, + logPath: string +): void { + const branches = ["stack-bottom", "stack-middle", "stack-top"]; + const worktrees = branches.map((branch) => ({ + branch, + worktreePath: path.join(worktreeRoot, branch), + })); + const worktreeList = worktrees + .map( + ({ branch, worktreePath }) => + String.raw`if [[ -d ${JSON.stringify(worktreePath)} ]]; then + printf 'worktree %s\nHEAD abc1234\nbranch refs/heads/%s\n\n' ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)} +fi` + ) + .join("\n"); + const worktreeStatus = worktrees + .map( + ({ worktreePath }) => + String.raw`elif [[ "$*" == "-C ${worktreePath} status --short" ]]; then + printf ''` + ) + .join("\n"); + writeFileSync( + binaryPath, + String.raw`#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +if [[ "$*" == "rev-parse --show-toplevel" ]]; then + printf '%s\n' ${JSON.stringify(repoRoot)} +elif [[ "$*" == "rev-parse --abbrev-ref HEAD" ]]; then + printf 'main\n' +elif [[ "$*" == "rev-parse HEAD" ]]; then + printf 'abc1234abc1234abc1234abc1234abc1234abc12\n' +elif [[ "$*" == "rev-parse --abbrev-ref --symbolic-full-name ${"@{u}"}" ]]; then + printf 'origin/main\n' +elif [[ "$*" == "status --short" ]]; then + printf '' +elif [[ "$*" == "worktree list --porcelain" ]]; then +${worktreeList} +${worktreeStatus} +elif [[ "$1 $2" == "worktree remove" ]]; then + rmdir "$3" +elif [[ "$*" == "fetch --prune origin" || "$*" == "checkout main" || "$*" == "pull --ff-only origin main" ]]; then + printf '' +else + echo "unexpected git args: $*" >&2 + exit 2 +fi +` + ); + chmodSync(binaryPath, 0o755); +} + +function writeFakeGhForPullRequestStackCreation( + binaryPath: string, + logPath: string, + options: { + ambiguousChild?: boolean; + apiUnavailable?: boolean; + bottomIsCrossRepository?: boolean; + continuation?: boolean; + existingStackNumber?: number; + topBaseRefName?: string; + } = {} +): void { + const bottomSha = "4".repeat(40); + const topSha = "5".repeat(40); + const ambiguousChildSha = "6".repeat(40); + const continuationSha = "7".repeat(40); + writeFileSync( + binaryPath, + String.raw`#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +if [[ "$1" == "api" && "$2" == "graphql" && "$*" != *"--paginate"* ]]; then + printf '%s\n' '{"data":{"__type":{"fields":[{"name":"stack"},{"name":"stackEntry"}]}}}' +elif [[ "$1" == "api" && "$2" == "graphql" && "$*" == *"--paginate"* ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "main", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:00:00.000Z", + deletions: 0, + headRefName: "stack-create-bottom", + headRefOid: bottomSha, + isCrossRepository: options.bottomIsCrossRepository ?? false, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 21, + reviewDecision: "APPROVED", + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Create bottom", + updatedAt: "2026-07-30T11:00:00.000Z", + url: "https://github.test/pr/21", + ...(options.existingStackNumber + ? { + stack: { + baseRefName: "main", + number: options.existingStackNumber, + position: 1, + size: 2, + }, + } + : {}), + }) + )} + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: options.topBaseRefName ?? "stack-create-bottom", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:01:00.000Z", + deletions: 0, + headRefName: "stack-create-top", + headRefOid: topSha, + isCrossRepository: false, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 22, + reviewDecision: null, + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Create top", + updatedAt: "2026-07-30T11:01:00.000Z", + url: "https://github.test/pr/22", + }) + )} + ${ + options.ambiguousChild + ? `printf '%s\\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "stack-create-bottom", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:02:00.000Z", + deletions: 0, + headRefName: "stack-create-parallel", + headRefOid: ambiguousChildSha, + isCrossRepository: false, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 23, + reviewDecision: null, + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Create parallel child", + updatedAt: "2026-07-30T11:02:00.000Z", + url: "https://github.test/pr/23", + }) + )}` + : "" + } + ${ + options.continuation + ? `printf '%s\\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "stack-create-top", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:03:00.000Z", + deletions: 0, + headRefName: "stack-create-continuation", + headRefOid: continuationSha, + isCrossRepository: false, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 24, + reviewDecision: null, + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Create continuation", + updatedAt: "2026-07-30T11:03:00.000Z", + url: "https://github.test/pr/24", + }) + )}` + : "" + } +elif [[ "$1 $2 $3" == "pr view 22" ]]; then + printf '%s\n' ${JSON.stringify( + JSON.stringify({ + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "stack-create-bottom", + body: "", + changedFiles: 1, + createdAt: "2026-07-30T10:01:00.000Z", + deletions: 0, + headRefName: "stack-create-top", + headRefOid: topSha, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 22, + reviewDecision: null, + reviews: [], + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: "Create top", + updatedAt: "2026-07-30T11:01:00.000Z", + url: "https://github.test/pr/22", + }) + )} +elif [[ "$1" == "api" && "$2" == "repos/rajohan/Mira-Dashboard/stacks?pull_request=22&per_page=2" ]]; then + printf '[]\n' +elif [[ "$1 $2 $3" == "pr review 22" ]]; then + printf 'review ok\n' +elif [[ "$1 $2 $3" == "api -X POST" && "$4" == "repos/rajohan/Mira-Dashboard/stacks" ]]; then + ${ + options.apiUnavailable + ? 'printf \'HTTP/2 404 Not Found\\ncontent-type: application/json\\n\\n{"message":"Not Found"}\\n\'\n exit 1' + : `printf '%s\\n' ${JSON.stringify( + JSON.stringify({ + base: { ref: "main" }, + created_at: "2026-07-30T12:00:00.000Z", + id: 500, + node_id: "S_stack500", + number: 500, + open: true, + pull_requests: [ + { + draft: false, + head: { ref: "stack-create-bottom", sha: bottomSha }, + merged_at: null, + number: 21, + state: "open", + }, + { + draft: false, + head: { ref: "stack-create-top", sha: topSha }, + merged_at: null, + number: 22, + state: "open", + }, + ], + url: "https://api.github.test/stacks/500", + }) + )}` + } +else + echo "unexpected gh args: $*" >&2 + exit 2 +fi +` + ); + chmodSync(binaryPath, 0o755); +} + +function stackPullRequestSummary( + number: 11 | 12 | 13, + overrides: Partial = {} +): PullRequestSummary { + const position = number - 10; + const headRefNames = { + 11: "stack-bottom", + 12: "stack-middle", + 13: "stack-top", + } as const; + const baseRefNames = { + 11: "main", + 12: "stack-bottom", + 13: "stack-middle", + } as const; + return { + additions: 1, + author: { login: "mira-2026" }, + baseRefName: baseRefNames[number], + body: "", + changedFiles: 1, + createdAt: `2026-07-30T10:0${position}:00.000Z`, + deletions: 0, + headRefName: headRefNames[number], + headRefOid: String(position).repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number, + reviewDecision: "APPROVED", + stack: { + baseRefName: "main", + number: 360, + position, + size: 3, + }, + statusCheckRollup: [{ conclusion: "success", name: "ci" }], + title: `Stack PR ${number}`, + updatedAt: `2026-07-30T11:0${position}:00.000Z`, + url: `https://github.test/pr/${number}`, + ...overrides, + }; +} + +function expectedStackHeadsThrough(number: 11 | 12 | 13) { + return ([11, 12, 13] as const) + .filter((pullRequestNumber) => pullRequestNumber <= number) + .map((pullRequestNumber) => ({ + headSha: String(pullRequestNumber - 10).repeat(40), + number: pullRequestNumber, + })); +} + +function writeFakeGhForNativeStackReviewApproval( + binaryPath: string, + logPath: string +): void { + const reviewedPath = `${logPath}.reviewed`; + const bottomSha = "1".repeat(40); + const middleSha = "2".repeat(40); + const bottom = stackPullRequestSummary(11, { + stack: { + baseRefName: "main", + number: 360, + position: 1, + size: 2, + }, + }); + const middle = stackPullRequestSummary(12, { + reviewDecision: undefined, + stack: { + baseRefName: "main", + number: 360, + position: 2, + size: 2, + }, + }); + const { stack: _stack, ...directMiddle } = middle; + const directMiddleBeforeReview = JSON.stringify({ + ...directMiddle, + reviews: [], + }); + const directMiddleAfterReview = JSON.stringify({ + ...directMiddle, + reviewDecision: "APPROVED", + reviews: [], + }); + const nativeStack = JSON.stringify([ + { + base: { ref: "main" }, + created_at: "2026-07-30T10:05:00.000Z", + id: 360, + node_id: "S_stack360", + number: 360, + open: true, + pull_requests: [ + { + draft: false, + head: { ref: "stack-bottom", sha: bottomSha }, + merged_at: null, + number: 11, + state: "open", + }, + { + draft: false, + head: { ref: "stack-middle", sha: middleSha }, + merged_at: null, + number: 12, + state: "open", + }, + ], + url: "https://api.github.test/stacks/360", + }, + ]); + const bottomRow = JSON.stringify(bottom); + const approvedMiddleRow = JSON.stringify({ + ...middle, + reviewDecision: "APPROVED", + }); + writeFileSync( + binaryPath, + String.raw`#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +set -- "$@" "" "" "" "" +if [[ "$1 $2 $3" == "pr view 12" ]]; then + if [[ -f ${JSON.stringify(reviewedPath)} ]]; then + printf '%s\n' ${JSON.stringify(directMiddleAfterReview)} + else + printf '%s\n' ${JSON.stringify(directMiddleBeforeReview)} + fi +elif [[ "$1" == "api" && "$2" == "repos/rajohan/Mira-Dashboard/stacks?pull_request=12&per_page=2" ]]; then + printf '%s\n' ${JSON.stringify(nativeStack)} +elif [[ "$1 $2 $3" == "pr review 12" ]]; then + touch ${JSON.stringify(reviewedPath)} + printf 'review ok\n' +elif [[ "$1" == "api" && "$2" == "graphql" && "$*" != *"--paginate"* ]]; then + printf '%s\n' '{"data":{"__type":{"fields":[{"name":"stack"},{"name":"stackEntry"}]}}}' +elif [[ "$1" == "api" && "$2" == "graphql" && "$*" == *"--paginate"* ]]; then + printf '%s\n' ${JSON.stringify(bottomRow)} + printf '%s\n' ${JSON.stringify(approvedMiddleRow)} else echo "unexpected gh args: $*" >&2 exit 2 @@ -2496,7 +3248,9 @@ printf 'scheduled\n' await import("../src/services/pullRequests.ts"); const { cancelJobExecution } = await import("../src/services/jobExecutionQueue.ts"); - const approval = runPullRequestApproval(11, false); + const approval = runPullRequestApproval(11, false, { + expectedHeadSha: "1".repeat(40), + }); let approvalExecutionId: string | undefined; let deploymentId: string | undefined; try { @@ -3311,25 +4065,47 @@ fi } = await import("../src/services/pullRequests.ts"); const pullRequests = await listDashboardPullRequests(); - expect(pullRequests.map((pullRequest) => pullRequest.number)).toEqual([3, 2, 1]); + expect(pullRequests.map((pullRequest) => pullRequest.number)).toEqual([ + 4, 3, 2, 1, 5, + ]); expect(pullRequests[0]).toMatchObject({ + baseRefName: "ready", canReviewerApprove: true, - number: 3, + number: 4, previewEligible: false, reviewerApproved: false, + stack: { + baseRefName: "main", + number: 42, + position: 2, + size: 2, + }, }); - expect(pullRequests[0]?.author).toBeUndefined(); - expect(pullRequests[1]).toMatchObject({ + expect(pullRequests[1]?.author).toBeUndefined(); + expect(pullRequests[2]).toMatchObject({ number: 2, title: "Blocked refreshed PR", headRefOid: "head2b", reviewerApproved: true, canReviewerApprove: false, }); - expect(pullRequests[2]).toMatchObject({ + expect(pullRequests[3]).toMatchObject({ number: 1, reviewerApproved: true, canReviewerApprove: false, + stack: { + baseRefName: "main", + number: 42, + position: 1, + size: 2, + }, + }); + expect(pullRequests[4]).toMatchObject({ + canReviewerApprove: true, + isCrossRepository: true, + number: 5, + previewEligible: false, + reviewerApproved: false, }); expect(isDashboardPullRequestOpen(2)).resolves.toBe(true); expect(isDashboardPullRequestOpen(99)).resolves.toBe(false); @@ -3339,84 +4115,392 @@ fi } }); - it("drives pull request review, branch update, and reject actions through fake GitHub CLI", async () => { + it("keeps ordinary Delivery PR listing available without preview stack fields", async () => { rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); - rememberEnvironment("RAJOHAN_GITHUB_TOKEN"); - const fakeRoot = createTemporaryRoot("mira-pr-actions-root-"); - const fakeBin = createTemporaryRoot("mira-pr-actions-bin-"); + const fakeRoot = createTemporaryRoot("mira-pr-list-fallback-root-"); + const fakeBin = createTemporaryRoot("mira-pr-list-fallback-bin-"); const ghLog = path.join(fakeRoot, "gh.log"); - writeFakeGhForPullRequestActions(path.join(fakeBin, "gh"), ghLog); - writeFileSync( - path.join(fakeBin, "git"), - `#!/usr/bin/env bash -set -euo pipefail -if [[ "$*" == "worktree list --porcelain" ]]; then - printf '' -else - echo "unexpected git args: $*" >&2 - exit 2 -fi -` - ); - chmodSync(path.join(fakeBin, "git"), 0o755); + writeFakeGhWithoutStackGraphqlFields(path.join(fakeBin, "gh"), ghLog); process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; - process.env.MIRA_DASHBOARD_WORKTREE_ROOT = path.join(fakeRoot, "worktrees"); - process.env.RAJOHAN_GITHUB_TOKEN = "review-token"; - const { - approvePullRequestReview, - registerPullRequestExecutionActions, - rejectPullRequest, - updatePullRequestBranch, - } = await import("../src/services/pullRequests.ts"); - registerPullRequestExecutionActions(); - cleanupCallbacks.push(() => { - database - .prepare( - `DELETE FROM job_executions - WHERE action_key IN ( - 'github.review-approval', - 'github.update-branch', - 'github.reject' - )` - ) - .run(); - }); - await startTestScheduledExecutor(); - const { pullRequestRoutes } = await import("../src/routes/pullRequestRoutes.ts"); + const { listDashboardPullRequests } = + await import("../src/services/pullRequests.ts"); - expect(approvePullRequestReview(3)).resolves.toMatchObject({ + const pullRequests = await listDashboardPullRequests(); + expect(pullRequests).toEqual([ + expect.objectContaining({ + number: 31, + title: "Fallback PR", + }), + ]); + expect(await listDashboardPullRequests()).toEqual(pullRequests); + expect(pullRequests[0]?.stack).toBeUndefined(); + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands.match(/api graphql/gu)).toHaveLength(3); + expect(ghCommands.match(/__type\(name: "PullRequest"\)/gu)).toHaveLength(1); + expect(ghCommands).toContain('__type(name: "PullRequest")'); + expect(ghCommands).not.toContain("stackEntry"); + }); + + it("lists ordinary pull requests when the optional stack capability probe fails", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-list-probe-failure-root-"); + const fakeBin = createTemporaryRoot("mira-pr-list-probe-failure-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + writeFakeGhWithoutStackGraphqlFields(path.join(fakeBin, "gh"), ghLog, true); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + + const { listDashboardPullRequests } = + await import("../src/services/pullRequests.ts"); + + const pullRequests = await listDashboardPullRequests(); + expect(pullRequests).toEqual([ + expect.objectContaining({ + number: 31, + title: "Fallback PR", + }), + ]); + expect(await listDashboardPullRequests()).toEqual(pullRequests); + expect(pullRequests[0]?.stack).toBeUndefined(); + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands.match(/api graphql/gu)).toHaveLength(3); + expect(ghCommands.match(/__type\(name: "PullRequest"\)/gu)).toHaveLength(1); + expect(ghCommands).not.toContain("stackEntry"); + }); + + it("creates a native GitHub stack only from an existing linear PR chain", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-stack-create-root-"); + const fakeBin = createTemporaryRoot("mira-pr-stack-create-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + + const { createPullRequestStack } = + await import("../src/services/pullRequests.ts"); + + const creation = await createPullRequestStack([21, 22]); + expect(creation).toEqual({ isOk: true, - message: "PR #3 review approved", - pullRequest: { - canReviewerApprove: true, - number: 3, - reviewerApproved: false, - }, + message: "GitHub stack #500 created with 2 PRs", }); - expect(updatePullRequestBranch(4)).resolves.toMatchObject({ - isOk: true, - message: "PR #4 branch update started", - pullRequest: { number: 4 }, + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands).toContain("api -X POST repos/rajohan/Mira-Dashboard/stacks"); + expect(ghCommands).toContain("pull_requests[]=21"); + expect(ghCommands).toContain("pull_requests[]=22"); + + expect( + await captureRejection(() => createPullRequestStack([22, 21])) + ).toMatchObject({ message: "The bottom pull request must target main" }); + expect( + await captureRejection(() => createPullRequestStack([21, 21])) + ).toMatchObject({ message: "A stack cannot contain duplicate pull requests" }); + + expect( + await captureRejection(() => createPullRequestStack([21, 23])) + ).toMatchObject({ + message: "PR #23 is not an open pull request in this repository", }); - expect(rejectPullRequest(5, "Not ready")).resolves.toMatchObject({ - cleanup: { - branch: "close-branch", - status: "skipped", - }, - isOk: true, - message: "PR #5 closed", - previewCleanup: { - number: 5, - status: "skipped", - }, + + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog, { + existingStackNumber: 499, + }); + expect( + await captureRejection(() => createPullRequestStack([21, 22])) + ).toMatchObject({ + message: "PR #21 already belongs to GitHub stack #499", }); - const reviewRoute = await pullRequestRoutes[ - "/api/pull-requests/:number/review-approval" + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog, { + topBaseRefName: "another-branch", + }); + expect( + await captureRejection(() => createPullRequestStack([21, 22])) + ).toMatchObject({ message: "PR #22 must target stack-create-bottom" }); + + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog, { + ambiguousChild: true, + }); + expect( + await captureRejection(() => createPullRequestStack([21, 22])) + ).toMatchObject({ + message: + "PR #21 has multiple open dependent pull requests; only a complete linear chain can become a GitHub stack", + statusCode: 409, + }); + + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog, { + continuation: true, + }); + expect( + await captureRejection(() => createPullRequestStack([21, 22])) + ).toMatchObject({ + message: "PR #24 depends on PR #22 and must be included in the GitHub stack", + statusCode: 409, + }); + + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog, { + bottomIsCrossRepository: true, + }); + expect( + await captureRejection(() => createPullRequestStack([21, 22])) + ).toMatchObject({ + message: "PR #21 is cross-repository and cannot join a GitHub stack", + statusCode: 409, + }); + + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog, { + apiUnavailable: true, + }); + expect( + await captureRejection(() => createPullRequestStack([21, 22])) + ).toMatchObject({ + message: "GitHub stacks are not enabled for this repository or token", + }); + }); + + it("revalidates every native stack layer before starting its preview", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-stack-preview-scope-root-"); + const fakeBin = createTemporaryRoot("mira-pr-stack-preview-scope-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + writeFakeGhForPullRequestStackMerge(path.join(fakeBin, "gh"), ghLog, "merged"); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + + const { validatePullRequestPreviewScope } = + await import("../src/services/pullRequests.ts"); + const scope = [ + stackPullRequestSummary(11), + stackPullRequestSummary(12), + stackPullRequestSummary(13), + ]; + + expect(await validatePullRequestPreviewScope(scope[2]!, scope)).toBeUndefined(); + const draftScope = [scope[0]!, scope[1]!, { ...scope[2]!, isDraft: true }]; + expect( + await validatePullRequestPreviewScope(draftScope[2]!, draftScope) + ).toBeUndefined(); + expect( + await validatePullRequestPreviewScope( + { ...scope[2]!, stack: undefined }, + scope + ) + ).toBeUndefined(); + expect( + await captureRejection(() => + validatePullRequestPreviewScope(scope[2]!, [ + { ...scope[0]!, headRefOid: "9".repeat(40) }, + scope[1]!, + scope[2]!, + ]) + ) + ).toMatchObject({ + message: "PR #11 changed while Delivery loaded the stack preview", + }); + expect( + await captureRejection(() => + validatePullRequestPreviewScope(scope[2]!, [scope[0]!, scope[2]!]) + ) + ).toMatchObject({ + message: "PR #12 changed while Delivery loaded the stack preview", + }); + + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "merged", + 13, + { closedNumber: 12 } + ); + expect( + await captureRejection(() => + validatePullRequestPreviewScope(scope[2]!, scope) + ) + ).toMatchObject({ + message: "PR #12 is closed and blocks this stack preview", + }); + }); + + it("excludes fork pull requests from inferred preview stack ancestry", async () => { + const { pullRequestPreviewScope } = + await import("../src/services/pullRequests.ts"); + const forkBottom = stackPullRequestSummary(11, { + headRefName: "shared-base", + isCrossRepository: true, + stack: undefined, + }); + const child = stackPullRequestSummary(12, { + baseRefName: "shared-base", + headRefName: "same-repository-child", + stack: undefined, + }); + + expect(pullRequestPreviewScope(child, [forkBottom, child])).toBeUndefined(); + expect( + pullRequestPreviewScope(child, [ + { ...forkBottom, isCrossRepository: false }, + child, + ])?.map((pullRequest) => pullRequest.number) + ).toEqual([11, 12]); + }); + + it("allows review approval on an upper linear stack candidate", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("RAJOHAN_GITHUB_TOKEN"); + const fakeRoot = createTemporaryRoot("mira-pr-candidate-review-root-"); + const fakeBin = createTemporaryRoot("mira-pr-candidate-review-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + writeFakeGhForPullRequestStackCreation(path.join(fakeBin, "gh"), ghLog); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.RAJOHAN_GITHUB_TOKEN = "test-review-token"; + + const { approvePullRequestReview, listDashboardPullRequests } = + await import("../src/services/pullRequests.ts"); + const pullRequests = await listDashboardPullRequests(); + const candidate = pullRequests.find((pullRequest) => pullRequest.number === 22); + expect(candidate).toMatchObject({ + canReviewerApprove: true, + previewEligible: true, + }); + const result = await approvePullRequestReview(22); + + expect(result).toMatchObject({ + isOk: true, + message: "PR #22 review approved", + pullRequest: { + number: 22, + previewEligible: true, + }, + }); + expect(await Bun.file(ghLog).text()).toContain( + "pr review 22 --approve --repo rajohan/Mira-Dashboard" + ); + }); + + it("allows review approval on an upper native GitHub stack layer", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("RAJOHAN_GITHUB_TOKEN"); + const fakeRoot = createTemporaryRoot("mira-pr-native-stack-review-root-"); + const fakeBin = createTemporaryRoot("mira-pr-native-stack-review-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + writeFakeGhForNativeStackReviewApproval(path.join(fakeBin, "gh"), ghLog); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.RAJOHAN_GITHUB_TOKEN = "test-review-token"; + + const { approvePullRequestReview } = + await import("../src/services/pullRequests.ts"); + const result = await approvePullRequestReview(12); + + expect(result).toMatchObject({ + isOk: true, + message: "PR #12 review approved", + pullRequest: { + canReviewerApprove: false, + number: 12, + previewEligible: true, + reviewerApproved: true, + stack: { + number: 360, + position: 2, + size: 2, + }, + }, + }); + expect(await Bun.file(ghLog).text()).toContain( + "pr review 12 --approve --repo rajohan/Mira-Dashboard" + ); + }); + + it("drives pull request review, branch update, and reject actions through fake GitHub CLI", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + rememberEnvironment("RAJOHAN_GITHUB_TOKEN"); + const fakeRoot = createTemporaryRoot("mira-pr-actions-root-"); + const fakeBin = createTemporaryRoot("mira-pr-actions-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + writeFakeGhForPullRequestActions(path.join(fakeBin, "gh"), ghLog); + writeFileSync( + path.join(fakeBin, "git"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "worktree list --porcelain" ]]; then + printf '' +else + echo "unexpected git args: $*" >&2 + exit 2 +fi +` + ); + chmodSync(path.join(fakeBin, "git"), 0o755); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = path.join(fakeRoot, "worktrees"); + process.env.RAJOHAN_GITHUB_TOKEN = "review-token"; + + const { + approvePullRequestReview, + registerPullRequestExecutionActions, + rejectPullRequest, + updatePullRequestBranch, + } = await import("../src/services/pullRequests.ts"); + registerPullRequestExecutionActions(); + cleanupCallbacks.push(() => { + database + .prepare( + `DELETE FROM job_executions + WHERE action_key IN ( + 'github.review-approval', + 'github.update-branch', + 'github.reject' + )` + ) + .run(); + }); + await startTestScheduledExecutor(); + const { pullRequestRoutes } = await import("../src/routes/pullRequestRoutes.ts"); + + expect(approvePullRequestReview(3)).resolves.toMatchObject({ + isOk: true, + message: "PR #3 review approved", + pullRequest: { + canReviewerApprove: true, + number: 3, + reviewerApproved: false, + }, + }); + expect(updatePullRequestBranch(4)).resolves.toMatchObject({ + isOk: true, + message: "PR #4 branch update started", + pullRequest: { number: 4 }, + }); + expect(rejectPullRequest(5, "Not ready")).resolves.toMatchObject({ + cleanup: { + branch: "close-branch", + status: "skipped", + }, + isOk: true, + message: "PR #5 closed", + previewCleanup: { + number: 5, + status: "skipped", + }, + }); + + const reviewRoute = await pullRequestRoutes[ + "/api/pull-requests/:number/review-approval" ].POST(routeRequest("/api/pull-requests/3/review-approval", { number: "3" })); expect(reviewRoute.json()).resolves.toMatchObject({ isOk: true, @@ -3502,6 +4586,28 @@ fi expect(malformedApproveRoute.json()).resolves.toMatchObject( apiErrorExpectation(expect.stringContaining("JSON")) ); + const missingApproveHeadRoute = await pullRequestRoutes[ + "/api/pull-requests/:number/approve" + ].POST( + routeRequest( + "/api/pull-requests/3/approve", + { number: "3" }, + { + body: JSON.stringify({ deploy: false }), + headers: { "Content-Type": "application/json" }, + method: "POST", + } + ) + ); + expect(missingApproveHeadRoute.status).toBe(400); + expect(await missingApproveHeadRoute.json()).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [{ path: "body.expectedHeadSha" }], + }, + }, + }); expect(Bun.file(ghLog).text()).resolves.toContain("pr review 3"); expect(Bun.file(ghLog).text()).resolves.toContain( @@ -3562,11 +4668,26 @@ fi process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; try { - const { registerPullRequestExecutionActions, runPullRequestApproval } = - await import("../src/services/pullRequests.ts"); + const { + approvePullRequest, + registerPullRequestExecutionActions, + runPullRequestApproval, + } = await import("../src/services/pullRequests.ts"); + expect( + await captureRejection(() => + approvePullRequest(11, false, { + expectedHeadSha: "2".repeat(40), + }) + ) + ).toMatchObject({ + message: + "PR #11 changed after the Delivery page loaded. Refresh before merging", + }); registerPullRequestExecutionActions(); await startTestScheduledExecutor(); - const result = await runPullRequestApproval(11, false); + const result = await runPullRequestApproval(11, false, { + expectedHeadSha: "1".repeat(40), + }); expect(result).toMatchObject({ cleanup: { @@ -3581,8 +4702,10 @@ fi status: "skipped", }, }); - expect(Bun.file(ghLog).text()).resolves.toContain("pr merge 11"); - expect(Bun.file(gitLog).text()).resolves.toContain("worktree remove"); + expect(await Bun.file(ghLog).text()).toContain( + `pr merge 11 --squash --delete-branch --repo rajohan/Mira-Dashboard --match-head-commit ${"1".repeat(40)}` + ); + expect(await Bun.file(gitLog).text()).toContain("worktree remove"); expect(existsSync(localWorktree)).toBe(false); expect( database @@ -3611,6 +4734,547 @@ fi } }); + it("merges a native stack and removes every worktree confirmed in that merge", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-stack-merge-root-"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot("mira-pr-stack-merge-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + const branches = ["stack-bottom", "stack-middle", "stack-top"]; + for (const branch of branches) { + mkdirSync(path.join(worktreeRoot, branch), { recursive: true }); + } + writeFakeGhForPullRequestStackMerge(path.join(fakeBin, "gh"), ghLog, "merged"); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const { approvePullRequest } = await import("../src/services/pullRequests.ts"); + expect( + await captureRejection(() => + approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + mergeStack: true, + }) + ) + ).toMatchObject({ + message: + "Native stack merge requires the expected head of every included pull request", + statusCode: 400, + }); + const result = await approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }); + + expect(result).toMatchObject({ + cleanups: branches.map((branch) => ({ + branch, + message: `Removed local worktree for ${branch}`, + status: "removed", + })), + isOk: true, + mergeStatus: "merged", + message: "Stack #360 merged through PR #13 (3 PRs)", + previewCleanups: [11, 12, 13].map((number) => ({ + number, + status: "skipped", + })), + }); + for (const branch of branches) { + expect(existsSync(path.join(worktreeRoot, branch))).toBe(false); + } + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands).toContain( + "api -X PUT repos/rajohan/Mira-Dashboard/pulls/13/merge-async" + ); + expect(ghCommands).toContain("merge_action=default"); + expect(ghCommands).toContain(`sha=${"3".repeat(40)}`); + const gitCommands = await Bun.file(gitLog).text(); + for (const branch of branches) { + expect(gitCommands).toContain( + `worktree remove ${path.join(worktreeRoot, branch)}` + ); + } + }); + + it("polls pending stack merges and rejects inconsistent asynchronous results", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-stack-async-root-"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot("mira-pr-stack-async-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + for (const branch of ["stack-bottom", "stack-middle", "stack-top"]) { + mkdirSync(path.join(worktreeRoot, branch), { recursive: true }); + } + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "pending-merged" + ); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const { approvePullRequest } = await import("../src/services/pullRequests.ts"); + const pendingResult = await approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }); + expect(pendingResult).toMatchObject({ + isOk: true, + mergeStatus: "merged", + }); + const pendingCommands = await Bun.file(ghLog).text(); + expect(pendingCommands).toContain("pulls/13/merge-async/merge-uuid"); + + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "head-mismatch" + ); + expect( + await captureRejection(() => + approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }) + ) + ).toMatchObject({ + message: + "PR #13 changed while GitHub accepted the stack merge. Verify the stack state before retrying", + }); + + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "pending-missing-id" + ); + expect( + await captureRejection(() => + approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }) + ) + ).toMatchObject({ + message: "GitHub stack merge returned pending without a result id", + }); + + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "pending-options-mismatch" + ); + expect( + await captureRejection(() => + approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }) + ) + ).toMatchObject({ + message: "PR #13 already has an incompatible pending stack merge request", + }); + + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "request-error-merged" + ); + writeFileSync(gitLog, ""); + for (const branch of ["stack-bottom", "stack-middle", "stack-top"]) { + mkdirSync(path.join(worktreeRoot, branch), { recursive: true }); + } + expect( + await captureRejection(() => + approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }) + ) + ).toMatchObject({ + message: expect.stringContaining("request interrupted"), + }); + for (const branch of ["stack-bottom", "stack-middle", "stack-top"]) { + expect(existsSync(path.join(worktreeRoot, branch))).toBe(true); + } + expect(await Bun.file(gitLog).text()).not.toContain("worktree remove"); + + writeFileSync(ghLog, ""); + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "merged", + 13, + { changedHeadNumber: 11 } + ); + expect( + await captureRejection(() => + approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }) + ) + ).toMatchObject({ + message: + "PR #11 changed after the Delivery confirmation. Refresh before merging the stack", + }); + expect(await Bun.file(ghLog).text()).not.toContain("merge-async"); + }); + + it("blocks ordinary merge and reject actions for native stack members", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-stack-ordinary-guard-root-"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot("mira-pr-stack-ordinary-guard-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "merged", + 11 + ); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const { approvePullRequest, rejectPullRequest } = + await import("../src/services/pullRequests.ts"); + expect( + await captureRejection(() => + approvePullRequest(11, false, { + expectedHeadSha: "1".repeat(40), + mergeStack: false, + }) + ) + ).toMatchObject({ + message: + "PR #11 belongs to GitHub stack #360. Use the stack-aware merge flow", + }); + expect( + await captureRejection(() => rejectPullRequest(11, "Not this layer")) + ).toMatchObject({ + message: + "PR #11 belongs to GitHub stack #360. Use the stack-aware reject flow", + }); + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands).not.toContain("merge-async"); + expect(ghCommands).not.toContain("pr merge"); + expect(ghCommands).not.toContain("pr close"); + }); + + it("blocks ordinary merge and reject actions for stack candidates", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-candidate-guard-root-"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot("mira-pr-candidate-guard-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + writeFakeGhForPullRequestMerge(path.join(fakeBin, "gh"), ghLog, [11]); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const { approvePullRequest, rejectPullRequest } = + await import("../src/services/pullRequests.ts"); + expect( + await approvePullRequest(11, false, { + expectedHeadSha: "1".repeat(40), + mergeStack: false, + }) + ).toMatchObject({ + isOk: true, + message: "PR #11 merged", + }); + expect(await Bun.file(ghLog).text()).toContain("pr merge 11"); + + writeFileSync(ghLog, ""); + writeFakeGhForPullRequestMerge(path.join(fakeBin, "gh"), ghLog, [12]); + expect( + await captureRejection(() => + approvePullRequest(11, false, { + expectedHeadSha: "1".repeat(40), + mergeStack: false, + }) + ) + ).toMatchObject({ + message: + "PR #11 has an open dependent pull request. Create or restructure the stack before merge", + }); + expect( + await captureRejection(() => rejectPullRequest(11, "Not this chain")) + ).toMatchObject({ + message: + "PR #11 has an open dependent pull request. Create or restructure the stack before reject", + }); + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands).not.toContain("pr merge"); + expect(ghCommands).not.toContain("pr close"); + }); + + it("does not treat main-targeted pull requests as dependents of a fork head", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-fork-guard-root-"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot("mira-pr-fork-guard-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + writeFakeGhForPullRequestMerge(path.join(fakeBin, "gh"), ghLog, [12], { + headRefName: "main", + isCrossRepository: true, + }); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const { approvePullRequest } = await import("../src/services/pullRequests.ts"); + expect( + await approvePullRequest(11, false, { + expectedHeadSha: "1".repeat(40), + mergeStack: false, + }) + ).toMatchObject({ + isOk: true, + message: "PR #11 merged", + }); + + const ghCommands = await Bun.file(ghLog).text(); + expect(ghCommands).not.toContain("pr list"); + expect(ghCommands).toContain("pr merge 11"); + }); + + it("merges from the middle of a native stack and retains worktrees above it", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const fakeRoot = createTemporaryRoot("mira-pr-stack-middle-root-"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot("mira-pr-stack-middle-bin-"); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + const branches = ["stack-bottom", "stack-middle", "stack-top"]; + for (const branch of branches) { + mkdirSync(path.join(worktreeRoot, branch), { recursive: true }); + } + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "merged", + 12 + ); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const { approvePullRequest } = await import("../src/services/pullRequests.ts"); + const result = await approvePullRequest(12, false, { + expectedHeadSha: "2".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(12), + mergeStack: true, + }); + + expect(result).toMatchObject({ + cleanups: [ + { branch: "stack-bottom", status: "removed" }, + { branch: "stack-middle", status: "removed" }, + ], + mergeStatus: "merged", + message: "Stack #360 merged through PR #12 (2 PRs)", + previewCleanups: [ + { number: 11, status: "skipped" }, + { number: 12, status: "skipped" }, + ], + }); + expect(existsSync(path.join(worktreeRoot, "stack-bottom"))).toBe(false); + expect(existsSync(path.join(worktreeRoot, "stack-middle"))).toBe(false); + expect(existsSync(path.join(worktreeRoot, "stack-top"))).toBe(true); + expect(await Bun.file(ghLog).text()).toContain( + "repos/rajohan/Mira-Dashboard/pulls/12/merge-async" + ); + expect(await Bun.file(gitLog).text()).not.toContain( + `worktree remove ${path.join(worktreeRoot, "stack-top")}` + ); + }); + + it("retains every worktree for closed blockers and unconfirmed merge results", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const { approvePullRequest } = await import("../src/services/pullRequests.ts"); + + for (const scenario of ["closed", "head-mismatch", "unconfirmed"] as const) { + const fakeRoot = createTemporaryRoot(`mira-pr-stack-${scenario}-guard-root-`); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot(`mira-pr-stack-${scenario}-guard-bin-`); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + const branches = ["stack-bottom", "stack-middle", "stack-top"]; + for (const branch of branches) { + mkdirSync(path.join(worktreeRoot, branch), { recursive: true }); + } + const mergeOptions: Parameters< + typeof writeFakeGhForPullRequestStackMerge + >[4] = {}; + if (scenario === "closed") { + mergeOptions.closedNumber = 12; + } else if (scenario === "head-mismatch") { + mergeOptions.mismatchedConfirmedHeadNumber = 12; + } else { + mergeOptions.unconfirmedNumber = 12; + } + writeFakeGhForPullRequestStackMerge( + path.join(fakeBin, "gh"), + ghLog, + "merged", + 13, + mergeOptions + ); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const action = approvePullRequest(13, false, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }); + expect(await captureRejection(() => action)).toMatchObject({ + message: + scenario === "closed" + ? "PR #12 is closed and blocks merging through PR #13" + : "GitHub reported the stack merged, but PR #12 did not confirm as merged. Worktrees were retained; verify GitHub, then run production sync before deploying", + }); + for (const branch of branches) { + expect(existsSync(path.join(worktreeRoot, branch))).toBe(true); + } + expect(await Bun.file(gitLog).text()).not.toContain("worktree remove"); + } + }); + + it("keeps every stack worktree when GitHub queues or rejects the atomic merge", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); + const { approvePullRequest } = await import("../src/services/pullRequests.ts"); + + for (const status of ["enqueued", "failed"] as const) { + const fakeRoot = createTemporaryRoot(`mira-pr-stack-${status}-root-`); + const worktreeRoot = path.join(fakeRoot, "worktrees"); + const fakeBin = createTemporaryRoot(`mira-pr-stack-${status}-bin-`); + const ghLog = path.join(fakeRoot, "gh.log"); + const gitLog = path.join(fakeRoot, "git.log"); + const branches = ["stack-bottom", "stack-middle", "stack-top"]; + for (const branch of branches) { + mkdirSync(path.join(worktreeRoot, branch), { recursive: true }); + } + writeFakeGhForPullRequestStackMerge(path.join(fakeBin, "gh"), ghLog, status); + writeFakeGitForPullRequestStackMerge( + path.join(fakeBin, "git"), + fakeRoot, + worktreeRoot, + gitLog + ); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; + + const action = approvePullRequest(13, true, { + expectedHeadSha: "3".repeat(40), + expectedStackHeads: expectedStackHeadsThrough(13), + mergeStack: true, + }); + const outcome = await action.then( + (result) => ({ kind: "resolved" as const, result }), + (error: unknown) => ({ error, kind: "rejected" as const }) + ); + expect(outcome).toMatchObject( + status === "enqueued" + ? { + kind: "resolved", + result: { + isOk: true, + mergeStatus: "enqueued", + message: + "Stack #360 queued through PR #13 (3 PRs). Delivery retained every worktree and will not auto-deploy; deploy latest main after GitHub finishes the queue", + }, + } + : { + error: { message: "Required check failed." }, + kind: "rejected", + } + ); + for (const branch of branches) { + expect(existsSync(path.join(worktreeRoot, branch))).toBe(true); + } + const gitCommands = await Bun.file(gitLog).text(); + expect(gitCommands).not.toContain("worktree remove"); + expect(gitCommands).not.toContain("fetch --prune origin"); + } + }); + it("reports a successful merge separately from a failed production sync", async () => { rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); @@ -3659,7 +5323,9 @@ fi try { const { approvePullRequest } = await import("../src/services/pullRequests.ts"); - const result = await approvePullRequest(11, true); + const result = await approvePullRequest(11, true, { + expectedHeadSha: "1".repeat(40), + }); expect(result).toMatchObject({ cleanup: { @@ -3689,16 +5355,22 @@ fi }); it("rejects oversized GitHub JSON stream rows when listing pull requests", async () => { - const spawnSpy = jest.spyOn(processModule, "spawnProcess").mockImplementation( - () => - ({ + const spawnSpy = jest + .spyOn(processModule, "spawnProcess") + .mockImplementation((_executable, arguments_) => { + const isPullRequestList = arguments_.includes("--paginate"); + return { exited: Promise.resolve(0), kill: () => {}, pid: 12_345, stderr: readableUtf8Stream(""), - stdout: readableUtf8Stream(`${"x".repeat(1024 * 1024 + 1)}\n`), - }) as unknown as processModule.BunProcess - ); + stdout: readableUtf8Stream( + isPullRequestList + ? `${"x".repeat(1024 * 1024 + 1)}\n` + : '{"data":{"__type":{"fields":[{"name":"stack"},{"name":"stackEntry"}]}}}\n' + ), + } as unknown as processModule.BunProcess; + }); const killSpy = jest .spyOn(processModule, "killProcessGroup") .mockImplementation(() => {}); @@ -3706,9 +5378,9 @@ fi try { const { listDashboardPullRequests } = await import("../src/services/pullRequests.ts"); - expect(listDashboardPullRequests()).rejects.toThrow( - "GitHub CLI JSON line was too large" - ); + expect( + await captureRejection(() => listDashboardPullRequests()) + ).toMatchObject({ message: "GitHub CLI JSON line was too large" }); expect(killSpy).toHaveBeenCalledWith(expect.any(Object), "SIGTERM"); } finally { spawnSpy.mockRestore(); @@ -3770,24 +5442,32 @@ fi updatePullRequestBranch, } = await import("../src/services/pullRequests.ts"); - expect(approvePullRequest(6, false)).rejects.toThrow( - "Draft pull requests cannot be approved from the dashboard" - ); - expect(rejectPullRequest(7, "Wrong base")).rejects.toThrow( - "Only main-targeted pull requests can be managed here" - ); - expect(updatePullRequestBranch(8)).rejects.toThrow( - "Pull request branch is not behind the base branch" - ); - expect(updatePullRequestBranch(9)).rejects.toThrow( - "Pull request branch has merge conflicts" - ); - expect(approvePullRequestReview(10)).rejects.toThrow( - "Rajohan cannot approve his own pull request" - ); - expect(approvePullRequestReview(6)).rejects.toThrow( - "Draft pull requests cannot be approved from the dashboard" - ); + expect( + await captureRejection(() => + approvePullRequest(6, false, { + expectedHeadSha: "6".repeat(40), + }) + ) + ).toMatchObject({ + message: "Draft pull requests cannot be approved from the dashboard", + }); + expect( + await captureRejection(() => rejectPullRequest(7, "Wrong base")) + ).toMatchObject({ + message: "Only main-targeted pull requests can be managed here", + }); + expect(await captureRejection(() => updatePullRequestBranch(8))).toMatchObject({ + message: "Pull request branch is not behind the base branch", + }); + expect(await captureRejection(() => updatePullRequestBranch(9))).toMatchObject({ + message: "Pull request branch has merge conflicts", + }); + expect(await captureRejection(() => approvePullRequestReview(10))).toMatchObject({ + message: "Rajohan cannot approve his own pull request", + }); + expect(await captureRejection(() => approvePullRequestReview(6))).toMatchObject({ + message: "Draft pull requests cannot be approved from the dashboard", + }); }); it("refreshes weather cache through the Open-Meteo fallback when wttr fails", async () => { diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 7226d0d97..62cb1baad 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { Server } from "bun"; +import type { PullRequestSummary } from "../../contracts/delivery.ts"; import { database } from "../src/database.ts"; import * as databaseMigrationRunnerModule from "../src/databaseMigrationRunner.ts"; import { @@ -62,6 +63,7 @@ import { import { getResolvedRoots, parsePublicGithubPullRequests, + pullRequestPreviewScope, validatePrNumber, } from "../src/services/pullRequests.ts"; import { httpOrigin, httpUrl } from "./support/httpUrls.ts"; @@ -110,6 +112,27 @@ function callTestRoute( }); } +function previewPullRequest( + number: number, + headRefName: string, + baseRefName: string, + overrides: Partial = {} +): PullRequestSummary { + return { + author: { login: "mira-2026" }, + baseRefName, + createdAt: "2026-07-30T10:00:00.000Z", + headRefName, + headRefOid: String(number).repeat(40).slice(0, 40), + isDraft: false, + number, + title: `PR ${number}`, + updatedAt: "2026-07-30T11:00:00.000Z", + url: `https://github.test/pull/${number}`, + ...overrides, + }; +} + describe("backend service utilities", () => { it("maps credential-free public GitHub pull request metadata for dev previews", () => { const commitSha = "a".repeat(40); @@ -127,6 +150,21 @@ describe("backend service utilities", () => { updated_at: "2026-07-26T11:00:00.000Z", user: { login: "mira-2026" }, }, + { + base: { ref: "mira/preview" }, + body: "Stacked preview body", + created_at: "2026-07-26T11:00:00.000Z", + draft: false, + head: { + ref: "mira/stacked-preview", + sha: "b".repeat(40), + }, + html_url: "https://github.com/rajohan/Mira-Dashboard/pull/336", + number: 336, + title: "Stacked preview PR", + updated_at: "2026-07-26T12:00:00.000Z", + user: { login: "mira-2026" }, + }, ]) ).toEqual([ expect.objectContaining({ @@ -140,12 +178,56 @@ describe("backend service utilities", () => { reviewerApproved: false, statusCheckRollup: [], }), + expect.objectContaining({ + baseRefName: "mira/preview", + canReviewerApprove: true, + headRefName: "mira/stacked-preview", + number: 336, + previewEligible: true, + reviewerApproved: false, + statusCheckRollup: [], + }), ]); expect(() => parsePublicGithubPullRequests([{ number: 335 }])).toThrow( "publicPullRequests.0.base" ); }); + it("resolves exact main-rooted preview scopes for stacks and linear candidates", () => { + const stack = [ + previewPullRequest(1, "stack-bottom", "main", { + stack: { baseRefName: "main", number: 9, position: 1, size: 2 }, + }), + previewPullRequest(2, "stack-top", "stack-bottom", { + stack: { baseRefName: "main", number: 9, position: 2, size: 2 }, + }), + ]; + const candidate = [ + previewPullRequest(3, "candidate-bottom", "main"), + previewPullRequest(4, "candidate-top", "candidate-bottom"), + ]; + + const stackScope = pullRequestPreviewScope(stack[1] as PullRequestSummary, stack); + expect(stackScope?.map((member) => member.number)).toEqual([1, 2]); + expect( + pullRequestPreviewCandidate(stack[1] as PullRequestSummary, stackScope) + ).toMatchObject({ + authorLogins: ["mira-2026", "mira-2026"], + rootBaseRefName: "main", + }); + expect( + pullRequestPreviewScope(candidate[1] as PullRequestSummary, candidate)?.map( + (member) => member.number + ) + ).toEqual([3, 4]); + expect( + pullRequestPreviewScope(candidate[1] as PullRequestSummary, [ + ...candidate, + previewPullRequest(5, "candidate-parallel", "candidate-bottom"), + ]) + ).toBeUndefined(); + }); + it("compacts every heartbeat cache payload without dropping health failures", () => { const kopia = compactHeartbeatData("backup.kopia.status", { checkedAt: "checked", @@ -829,10 +911,10 @@ describe("backend service utilities", () => { title: "Managed preview", } as never) ).toEqual({ - authorLogin: "mira-2026", - baseRefName: "main", + authorLogins: ["mira-2026"], commitSha: "a".repeat(40), number: 335, + rootBaseRefName: "main", title: "Managed preview", }); diff --git a/contracts/delivery.ts b/contracts/delivery.ts index 441363db1..933fd367f 100644 --- a/contracts/delivery.ts +++ b/contracts/delivery.ts @@ -33,6 +33,25 @@ export const pullRequestReviewConnectionSchema = v.object({ nodes: v.optional(v.array(pullRequestReviewSchema)), }); +export const pullRequestStackSchema = v.strictObject({ + baseRefName: trimmedNonEmptyStringSchema, + number: positiveIntegerSchema, + position: positiveIntegerSchema, + size: positiveIntegerSchema, +}); + +export const pullRequestExpectedHeadSchema = v.strictObject({ + headSha: fullCommitShaSchema, + number: positiveIntegerSchema, +}); + +const optionalPullRequestStackSchema = v.optional( + v.pipe( + v.nullable(pullRequestStackSchema), + v.transform((value) => value ?? undefined) + ) +); + /** GitHub owns this evolving payload, so only Dashboard-consumed fields are retained. */ export const pullRequestSummarySchema = v.object({ additions: v.optional(finiteNumberSchema), @@ -45,6 +64,7 @@ export const pullRequestSummarySchema = v.object({ deletions: v.optional(finiteNumberSchema), headRefName: trimmedNonEmptyStringSchema, headRefOid: v.optional(trimmedNonEmptyStringSchema), + isCrossRepository: v.optional(v.boolean()), isDraft: v.boolean(), latestOpinionatedReviews: v.optional(pullRequestReviewConnectionSchema), mergeable: v.optional(v.string()), @@ -59,12 +79,20 @@ export const pullRequestSummarySchema = v.object({ ), reviewerApproved: v.optional(v.boolean()), reviews: v.optional(v.array(pullRequestReviewSchema)), + stack: optionalPullRequestStackSchema, statusCheckRollup: v.optional(v.array(v.unknown())), title: trimmedNonEmptyStringSchema, updatedAt: trimmedNonEmptyStringSchema, url: trimmedNonEmptyStringSchema, }); +const publicGitHubPullRequestStackSchema = v.object({ + base: v.object({ ref: trimmedNonEmptyStringSchema }), + number: positiveIntegerSchema, + position: positiveIntegerSchema, + size: positiveIntegerSchema, +}); + /** Bounded public GitHub REST shape used by credential-free development previews. */ export const publicGitHubPullRequestSchema = v.object({ base: v.object({ ref: trimmedNonEmptyStringSchema }), @@ -77,6 +105,7 @@ export const publicGitHubPullRequestSchema = v.object({ }), html_url: trimmedNonEmptyStringSchema, number: positiveIntegerSchema, + stack: v.optional(v.nullable(publicGitHubPullRequestStackSchema)), title: trimmedNonEmptyStringSchema, updated_at: trimmedNonEmptyStringSchema, user: v.object({ login: trimmedNonEmptyStringSchema }), @@ -88,9 +117,52 @@ export const publicGitHubPullRequestsSchema = v.pipe( ); export const gitHubPullRequestStateSchema = v.object({ + headRefOid: v.optional(fullCommitShaSchema), state: v.picklist(["CLOSED", "MERGED", "OPEN"]), }); +export const gitHubPullRequestStackResourceSchema = v.object({ + base: v.object({ ref: trimmedNonEmptyStringSchema }), + created_at: trimmedNonEmptyStringSchema, + id: positiveIntegerSchema, + node_id: trimmedNonEmptyStringSchema, + number: positiveIntegerSchema, + open: v.boolean(), + pull_requests: v.pipe( + v.array( + v.object({ + draft: v.boolean(), + head: v.object({ + ref: trimmedNonEmptyStringSchema, + sha: fullCommitShaSchema, + }), + merged_at: v.nullable(trimmedNonEmptyStringSchema), + number: positiveIntegerSchema, + state: v.picklist(["closed", "open"]), + }) + ), + v.maxLength(100) + ), + url: trimmedNonEmptyStringSchema, +}); + +export const gitHubPullRequestStacksSchema = v.pipe( + v.array(gitHubPullRequestStackResourceSchema), + v.maxLength(100) +); + +export const gitHubAsyncPullRequestMergeResultSchema = v.object({ + details: v.object({ + expected_head_sha: v.optional(fullCommitShaSchema), + merge_action: v.optional(v.picklist(["default", "direct_merge", "merge_queue"])), + merge_method: v.optional(v.picklist(["merge", "rebase", "squash"])), + message: v.string(), + sha: v.optional(fullCommitShaSchema), + uuid: v.optional(trimmedNonEmptyStringSchema), + }), + status: v.picklist(["enqueued", "failed", "merged", "pending"]), +}); + export const deploymentJobSchema = v.strictObject({ commit: v.optional(v.string()), commitTitle: v.optional(v.string()), @@ -198,12 +270,16 @@ export const pullRequestPreviewMutationResponseSchema = v.strictObject({ }); export const pullRequestActionResponseSchema = v.strictObject({ cleanup: v.optional(worktreeCleanupResultSchema), + cleanups: v.optional(v.array(worktreeCleanupResultSchema)), deployError: v.optional(v.string()), deployment: v.optional(deploymentJobSchema), isOk: v.boolean(), + mergeStatus: v.optional(v.picklist(["enqueued", "merged"])), message: v.string(), previewCleanup: v.optional(pullRequestPreviewCleanupResultSchema), + previewCleanups: v.optional(v.array(pullRequestPreviewCleanupResultSchema)), pullRequest: v.optional(pullRequestSummarySchema), + syncError: v.optional(v.string()), }); export const deploymentActionResponseSchema = v.strictObject({ deployment: deploymentJobSchema, @@ -212,6 +288,23 @@ export const deploymentActionResponseSchema = v.strictObject({ export const pullRequestApproveRequestSchema = strictJsonObjectSchema({ deploy: v.optional(v.boolean()), + expectedHeadSha: fullCommitShaSchema, + expectedStackHeads: v.optional( + v.pipe(v.array(pullRequestExpectedHeadSchema), v.minLength(1), v.maxLength(100)) + ), + mergeStack: v.optional(v.boolean()), +}); + +export const pullRequestStackCreateRequestSchema = strictJsonObjectSchema({ + pullRequests: v.pipe( + v.array(positiveIntegerSchema), + v.minLength(2), + v.maxLength(100) + ), +}); + +export const pullRequestPreviewStartRequestSchema = strictJsonObjectSchema({ + expectedHeadSha: fullCommitShaSchema, }); export const pullRequestRejectRequestSchema = strictJsonObjectSchema({ @@ -227,9 +320,17 @@ export type PullRequestReview = v.InferOutput; export type PullRequestReviewConnection = v.InferOutput< typeof pullRequestReviewConnectionSchema >; +export type PullRequestExpectedHead = v.InferOutput; +export type PullRequestStack = v.InferOutput; export type PullRequestSummary = v.InferOutput; export type PublicGitHubPullRequest = v.InferOutput; export type GitHubPullRequestState = v.InferOutput; +export type GitHubPullRequestStackResource = v.InferOutput< + typeof gitHubPullRequestStackResourceSchema +>; +export type GitHubAsyncPullRequestMergeResult = v.InferOutput< + typeof gitHubAsyncPullRequestMergeResultSchema +>; export type DeploymentJob = v.InferOutput; export type DashboardReleaseSummary = v.InferOutput; export type DashboardReleaseStatus = v.InferOutput; @@ -269,6 +370,12 @@ export type DeploymentActionResponse = v.InferOutput< export type PullRequestApproveRequest = v.InferOutput< typeof pullRequestApproveRequestSchema >; +export type PullRequestStackCreateRequest = v.InferOutput< + typeof pullRequestStackCreateRequestSchema +>; +export type PullRequestPreviewStartRequest = v.InferOutput< + typeof pullRequestPreviewStartRequestSchema +>; export type PullRequestRejectRequest = v.InferOutput< typeof pullRequestRejectRequestSchema >; @@ -287,6 +394,28 @@ export function parsePullRequestApproveRequest( return parseContract(pullRequestApproveRequestSchema, value); } +/** + * Parses a native GitHub stack creation request at the backend HTTP trust boundary. + * @param value Value to process. + * @returns Parsed native GitHub stack creation request. + */ +export function parsePullRequestStackCreateRequest( + value: unknown +): PullRequestStackCreateRequest { + return parseContract(pullRequestStackCreateRequestSchema, value); +} + +/** + * Parses an exact-head pull request preview request. + * @param value Value to process. + * @returns Parsed pull request preview request. + */ +export function parsePullRequestPreviewStartRequest( + value: unknown +): PullRequestPreviewStartRequest { + return parseContract(pullRequestPreviewStartRequestSchema, value); +} + /** * Parses a pull-request rejection request at the backend HTTP trust boundary. * @param value Value to process. @@ -344,6 +473,45 @@ export function parseGitHubPullRequestState( return parseContract(gitHubPullRequestStateSchema, value, path); } +/** + * Parses a native GitHub pull request stack resource. + * @param value Value to process. + * @param path File or resource path. + * @returns Parsed GitHub pull request stack. + */ +export function parseGitHubPullRequestStackResource( + value: unknown, + path = "pullRequestStack" +): GitHubPullRequestStackResource { + return parseContract(gitHubPullRequestStackResourceSchema, value, path); +} + +/** + * Parses a bounded collection of native GitHub pull request stacks. + * @param value Value to process. + * @param path File or resource path. + * @returns Parsed GitHub pull request stacks. + */ +export function parseGitHubPullRequestStacks( + value: unknown, + path = "pullRequestStacks" +): GitHubPullRequestStackResource[] { + return parseContract(gitHubPullRequestStacksSchema, value, path); +} + +/** + * Parses one asynchronous native GitHub pull request merge result. + * @param value Value to process. + * @param path File or resource path. + * @returns Parsed asynchronous merge result. + */ +export function parseGitHubAsyncPullRequestMergeResult( + value: unknown, + path = "pullRequestStackMerge" +): GitHubAsyncPullRequestMergeResult { + return parseContract(gitHubAsyncPullRequestMergeResultSchema, value, path); +} + /** * Parses one Dashboard deployment job. * @param value Value to process. diff --git a/frontend/src/components/features/delivery/pullRequestStacks.ts b/frontend/src/components/features/delivery/pullRequestStacks.ts new file mode 100644 index 000000000..4a400dfc2 --- /dev/null +++ b/frontend/src/components/features/delivery/pullRequestStacks.ts @@ -0,0 +1,156 @@ +import type { PullRequestSummary } from "../../../../../contracts/delivery"; + +export interface PullRequestStackCandidate { + baseRefName: string; + pullRequests: PullRequestSummary[]; +} + +export interface PullRequestStackCandidateEntry { + candidate: PullRequestStackCandidate; + position: number; +} + +export interface PullRequestStackGroup { + number: number; + pullRequests: PullRequestSummary[]; +} + +/** + * Finds unregistered linear pull request chains that GitHub can turn into stacks. + * @param pullRequests Current open pull requests. + * @param baseRefName Required stack base. + * @returns Linear candidates ordered independently from bottom to top. + */ +export function derivePullRequestStackCandidates( + pullRequests: PullRequestSummary[], + baseRefName: string +): PullRequestStackCandidate[] { + const unstackedPullRequests = pullRequests.filter( + (pullRequest) => + pullRequest.stack === undefined && pullRequest.isCrossRepository !== true + ); + const childrenByBase = new Map(); + for (const pullRequest of unstackedPullRequests) { + const children = childrenByBase.get(pullRequest.baseRefName) ?? []; + children.push(pullRequest); + childrenByBase.set(pullRequest.baseRefName, children); + } + + const candidates: PullRequestStackCandidate[] = []; + for (const bottomPullRequest of unstackedPullRequests) { + if (bottomPullRequest.baseRefName !== baseRefName) continue; + + const pullRequestChain = [bottomPullRequest]; + const numbers = new Set([bottomPullRequest.number]); + let currentPullRequest = bottomPullRequest; + let isAmbiguous = false; + while (true) { + const children = childrenByBase.get(currentPullRequest.headRefName) ?? []; + if (children.length === 0) break; + if (children.length > 1) { + isAmbiguous = true; + break; + } + + const child = children[0]; + if (!child || numbers.has(child.number)) { + isAmbiguous = true; + break; + } + pullRequestChain.push(child); + numbers.add(child.number); + currentPullRequest = child; + } + + if (!isAmbiguous && pullRequestChain.length >= 2) { + candidates.push({ + baseRefName, + pullRequests: pullRequestChain, + }); + } + } + return candidates; +} + +/** + * Indexes candidate membership by pull request number. + * @param candidates Linear stack candidates. + * @returns Candidate entry lookup. + */ +export function indexPullRequestStackCandidates( + candidates: PullRequestStackCandidate[] +): Map { + const entries = new Map(); + for (const candidate of candidates) { + for (const [index, pullRequest] of candidate.pullRequests.entries()) { + entries.set(pullRequest.number, { + candidate, + position: index + 1, + }); + } + } + return entries; +} + +/** + * Returns the open native stack members merged with a selected pull request. + * @param selectedPullRequest Highest pull request selected for merge. + * @param pullRequests Current open pull requests. + * @returns Open members ordered from bottom through the selected pull request. + */ +export function pullRequestStackMergeGroup( + selectedPullRequest: PullRequestSummary, + pullRequests: PullRequestSummary[] +): PullRequestSummary[] { + const stack = selectedPullRequest.stack; + if (!stack) return [selectedPullRequest]; + return pullRequests + .filter( + (pullRequest) => + pullRequest.stack?.number === stack.number && + pullRequest.stack.position <= stack.position + ) + .toSorted( + (left, right) => (left.stack?.position ?? 0) - (right.stack?.position ?? 0) + ); +} + +function latestUpdatedAt(pullRequestGroup: PullRequestSummary[]): string { + let latest = ""; + for (const pullRequest of pullRequestGroup) { + if (pullRequest.updatedAt > latest) latest = pullRequest.updatedAt; + } + return latest; +} + +/** + * Groups native GitHub stack members and orders each stack from bottom to top. + * @param pullRequests Current open pull requests. + * @returns Native stack groups ordered by their most recently updated member. + */ +export function groupNativePullRequestStacks( + pullRequests: PullRequestSummary[] +): PullRequestStackGroup[] { + const pullRequestsByStack = new Map(); + for (const pullRequest of pullRequests) { + const stackNumber = pullRequest.stack?.number; + if (stackNumber === undefined) continue; + const members = pullRequestsByStack.get(stackNumber) ?? []; + members.push(pullRequest); + pullRequestsByStack.set(stackNumber, members); + } + + return [...pullRequestsByStack] + .map(([number, members]) => ({ + number, + pullRequests: members.toSorted( + (left, right) => + (left.stack?.position ?? 0) - (right.stack?.position ?? 0) + ), + })) + .toSorted((left, right) => { + const leftUpdatedAt = latestUpdatedAt(left.pullRequests); + const rightUpdatedAt = latestUpdatedAt(right.pullRequests); + return rightUpdatedAt.localeCompare(leftUpdatedAt); + }); +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index bc50e14fa..22616e4c1 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -60,6 +60,7 @@ export { deliveryKeys, useApprovePullRequest, useApprovePullRequestReview, + useCreatePullRequestStack, useDashboardDeployments, useDashboardReleaseStatus, useDeployDashboard, diff --git a/frontend/src/hooks/useDelivery.ts b/frontend/src/hooks/useDelivery.ts index 13f4a5eb0..90c86d99a 100644 --- a/frontend/src/hooks/useDelivery.ts +++ b/frontend/src/hooks/useDelivery.ts @@ -16,8 +16,11 @@ import { type ProductionCheckoutStatus, type PullRequestActionResponse, type PullRequestApproveRequest, + type PullRequestExpectedHead, type PullRequestPreviewStatus, + type PullRequestPreviewStartRequest, type PullRequestRejectRequest, + type PullRequestStackCreateRequest, type PullRequestSummary, } from "../../../contracts/delivery"; import { AUTO_REFRESH_MS } from "../lib/queryClient"; @@ -98,21 +101,43 @@ async function fetchPullRequestPreview(): Promise { * Performs approve pull request. * @param number Number value. * @param willDeploy Whether will deploy. + * @param options Exact-head and native stack merge options. * @returns Approve pull request result. */ async function approvePullRequest( number: number, - willDeploy: boolean + willDeploy: boolean, + options: { + expectedHeadSha: string; + expectedStackHeads?: PullRequestExpectedHead[]; + mergeStack?: boolean; + } ): Promise { return apiPostParsed( `/pull-requests/${number}/approve`, parsePullRequestActionResponse, { deploy: willDeploy, + expectedHeadSha: options.expectedHeadSha, + expectedStackHeads: options.expectedStackHeads, + mergeStack: options.mergeStack, } satisfies PullRequestApproveRequest ); } +/** + * Creates a native GitHub stack from an existing linear pull request chain. + * @param pullRequests Pull request numbers ordered from bottom to top. + * @returns Stack creation result. + */ +async function createPullRequestStack( + pullRequests: number[] +): Promise { + return apiPostParsed("/pull-requests/stacks", parsePullRequestActionResponse, { + pullRequests, + } satisfies PullRequestStackCreateRequest); +} + /** * Performs reject pull request. * @param number Number value. @@ -186,15 +211,17 @@ async function rollbackDashboard( /** * Starts or updates the managed preview slot. * @param number Number value. + * @param expectedHeadSha Exact pull request head confirmed in Delivery. * @returns Promise resolving to the start pull request preview result. */ async function startPullRequestPreview( - number: number + number: number, + expectedHeadSha: string ): Promise { const response = await apiPostParsed( `/pull-requests/${number}/preview/start`, parsePullRequestPreviewMutationResponse, - {} + { expectedHeadSha } satisfies PullRequestPreviewStartRequest ); return response.preview; } @@ -289,8 +316,24 @@ export function useApprovePullRequest() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ number, willDeploy }: { number: number; willDeploy: boolean }) => - approvePullRequest(number, willDeploy), + mutationFn: ({ + expectedHeadSha, + expectedStackHeads, + mergeStack, + number, + willDeploy, + }: { + expectedHeadSha: string; + expectedStackHeads?: PullRequestExpectedHead[]; + mergeStack?: boolean; + number: number; + willDeploy: boolean; + }) => + approvePullRequest(number, willDeploy, { + expectedHeadSha, + expectedStackHeads, + mergeStack, + }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: deliveryKeys.list() }); void queryClient.invalidateQueries({ @@ -306,6 +349,22 @@ export function useApprovePullRequest() { }); } +/** + * Creates a native GitHub stack and refreshes Delivery metadata. + * @returns Native stack creation mutation. + */ +export function useCreatePullRequestStack() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ pullRequests }: { pullRequests: number[] }) => + createPullRequestStack(pullRequests), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: deliveryKeys.list() }); + }, + }); +} + /** * Provides approve pull request review. * @returns The approve pull request review. @@ -323,7 +382,16 @@ export function useApprovePullRequestReview() { (current = []) => current.map((pullRequest) => pullRequest.number === updatedPullRequest.number - ? updatedPullRequest + ? { + ...updatedPullRequest, + previewEligible: + updatedPullRequest.stack === undefined && + pullRequest.stack !== undefined + ? pullRequest.previewEligible + : updatedPullRequest.previewEligible, + stack: + updatedPullRequest.stack ?? pullRequest.stack, + } : pullRequest ) ); @@ -431,7 +499,13 @@ export function useStartPullRequestPreview() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ number }: { number: number }) => startPullRequestPreview(number), + mutationFn: ({ + expectedHeadSha, + number, + }: { + expectedHeadSha: string; + number: number; + }) => startPullRequestPreview(number, expectedHeadSha), onSuccess: (preview) => { queryClient.setQueryData(deliveryKeys.preview(), preview); void queryClient.invalidateQueries({ diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 31908ce41..df1afb9fd 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -19,11 +19,19 @@ import type { DashboardReleaseSummary, DeploymentJob, ProductionCheckoutStatus, + PullRequestExpectedHead, PullRequestPreviewStatus, PullRequestSummary, } from "../../../contracts/delivery"; import { ProductionReleasesCard } from "../components/features/delivery/ProductionReleasesCard"; import { PullRequestDevelopmentCard } from "../components/features/delivery/PullRequestDevelopmentCard"; +import { + derivePullRequestStackCandidates, + groupNativePullRequestStacks, + indexPullRequestStackCandidates, + type PullRequestStackCandidate, + pullRequestStackMergeGroup, +} from "../components/features/delivery/pullRequestStacks"; import { Alert } from "../components/ui/Alert"; import { Badge } from "../components/ui/Badge"; import { Button } from "../components/ui/Button"; @@ -35,6 +43,7 @@ import { RefreshButton } from "../components/ui/RefreshButton"; import { useApprovePullRequest, useApprovePullRequestReview, + useCreatePullRequestStack, useDashboardDeployments, useDashboardReleaseStatus, useDeployDashboard, @@ -53,14 +62,19 @@ import { formatDate } from "../utils/format"; /** Defines pending action. */ type PendingAction = | undefined - | { type: "merge"; pr: PullRequestSummary } - | { type: "merge-deploy"; pr: PullRequestSummary } + | { type: "merge"; pr: PullRequestSummary; scope: PullRequestSummary[] } + | { type: "merge-deploy"; pr: PullRequestSummary; scope: PullRequestSummary[] } | { type: "review-approve"; pr: PullRequestSummary } - | { type: "preview-rebuild"; pr: PullRequestSummary } - | { type: "preview-start"; pr: PullRequestSummary } + | { + type: "preview-rebuild"; + pr: PullRequestSummary; + scope: PullRequestSummary[]; + } + | { type: "preview-start"; pr: PullRequestSummary; scope: PullRequestSummary[] } | { number: number; title?: string; type: "preview-stop" } | { type: "reject"; pr: PullRequestSummary } | { release: DashboardReleaseSummary; type: "rollback" } + | { candidate: PullRequestStackCandidate; type: "stack-create" } | { type: "deploy" }; type PendingActionType = Exclude["type"]; type UnhandledPendingActionType = Exclude< @@ -74,6 +88,7 @@ type UnhandledPendingActionType = Exclude< | "reject" | "review-approve" | "rollback" + | "stack-create" >; const PENDING_ACTION_SWITCH_IS_EXHAUSTIVE: UnhandledPendingActionType extends never @@ -84,6 +99,7 @@ void PENDING_ACTION_SWITCH_IS_EXHAUSTIVE; const MIRA_AUTHOR = "mira-2026"; const DEFAULT_REVIEWER_AUTHOR = "rajohan"; const DEPENDABOT_AUTHOR = "app/dependabot"; +const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; const DEFAULT_BASE = "main"; const ACTIVE_PREVIEW_STATUSES = new Set([ "running", @@ -505,10 +521,10 @@ function canConfiguredReviewerApproveReview(pr: PullRequestSummary): boolean { function actionLabel(action: Exclude) { switch (action.type) { case "merge": { - return "Merge PR"; + return action.pr.stack ? "Merge stack" : "Merge PR"; } case "merge-deploy": { - return "Merge + Deploy"; + return action.pr.stack ? "Merge stack + Deploy" : "Merge + Deploy"; } case "review-approve": { return "Approve PR"; @@ -525,6 +541,9 @@ function actionLabel(action: Exclude) { case "reject": { return "Reject PR"; } + case "stack-create": { + return "Create GitHub stack"; + } case "deploy": { return `Deploy latest ${DEFAULT_BASE}`; } @@ -534,6 +553,47 @@ function actionLabel(action: Exclude) { } } +/** + * Formats the exact pull request heads included in a stack merge confirmation. + * @param pullRequests Pull requests ordered from bottom to top. + * @returns Bottom-to-top pull request numbers and abbreviated head SHAs. + */ +function exactPullRequestHeadSummary(pullRequests: PullRequestSummary[]): string { + return pullRequests + .map( + (pullRequest) => + `#${pullRequest.number} ${pullRequest.headRefOid?.slice(0, 8) ?? "unavailable"}` + ) + .join(" → "); +} + +/** + * Builds the exact per-layer head preconditions for a native stack merge. + * @param pullRequest Selected pull request. + * @param scope Pull requests included through the selected stack layer. + * @returns Expected stack heads, or undefined for a standalone pull request. + */ +function expectedStackHeadsForMerge( + pullRequest: PullRequestSummary, + scope: PullRequestSummary[] +): PullRequestExpectedHead[] | undefined { + if (!pullRequest.stack) return undefined; + return scope.map((candidate) => { + if ( + typeof candidate.headRefOid !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(candidate.headRefOid) + ) { + throw new Error( + `Refresh Delivery before merging because the exact head for PR #${candidate.number} is unavailable` + ); + } + return { + headSha: candidate.headRefOid, + number: candidate.number, + }; + }); +} + /** * Performs action message. * @returns Action message result. @@ -541,19 +601,31 @@ function actionLabel(action: Exclude) { function actionMessage(action: Exclude) { switch (action.type) { case "merge": { - return `Merge PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge the PR and delete the remote branch. It will not deploy.`; + if (action.pr.stack) { + return `Merge GitHub stack #${action.pr.stack.number} through PR #${action.pr.number}: ${action.pr.title}?\n\nIncluded exact heads: ${exactPullRequestHeadSummary(action.scope)}. GitHub will submit every open PR from the bottom of the stack through #${action.pr.number} as one all-or-nothing merge group. Direct merges use squash; a required merge queue uses its repository policy. Delivery removes each merged PR's clean local worktree and managed dev data only after every included PR confirms as merged. If GitHub queues the stack, Delivery retains every worktree. It will not deploy.`; + } + return `Merge PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge exact head ${action.pr.headRefOid?.slice(0, 8) ?? "shown in Delivery"} and delete the remote branch. It will not deploy.`; } case "merge-deploy": { - return `Merge and deploy PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge, sync ${DEFAULT_BASE}, publish an immutable release, atomically activate it, restart web and worker, and verify commit-bound readiness. A failed release is rolled back automatically.`; + if (action.pr.stack) { + return `Merge and deploy GitHub stack #${action.pr.stack.number} through PR #${action.pr.number}: ${action.pr.title}?\n\nIncluded exact heads: ${exactPullRequestHeadSummary(action.scope)}. GitHub will submit every open PR from the bottom of the stack through #${action.pr.number} as one all-or-nothing merge group. Direct merges use squash; a required merge queue uses its repository policy. After every included PR confirms as merged, Delivery removes its clean local worktree and managed dev data, syncs ${DEFAULT_BASE}, publishes an immutable release, atomically activates it, restarts web and worker, and verifies commit-bound readiness. If GitHub queues the stack, Delivery keeps all worktrees and does not auto-deploy; use Deploy latest ${DEFAULT_BASE} after the queue finishes.`; + } + return `Merge and deploy PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge exact head ${action.pr.headRefOid?.slice(0, 8) ?? "shown in Delivery"}, sync ${DEFAULT_BASE}, publish an immutable release, atomically activate it, restart web and worker, and verify commit-bound readiness. A failed release is rolled back automatically.`; } case "review-approve": { return `Approve PR #${action.pr.number}: ${action.pr.title}?\n\nThis approves the PR on GitHub. It does not merge or deploy.`; } case "preview-start": { - return `Run PR #${action.pr.number} in dev: ${action.pr.title}?\n\nThis runs the fixed PR commit over Tailscale HTTPS without source watchers, using an isolated Dashboard database, a writable workspace snapshot, and an isolated scheduler/worker without host or backup jobs. It connects to the live production Gateway so chat and session changes can affect production data. The dev environment stops automatically after four hours.`; + const includedPullRequests = action.scope + .map((pullRequest) => `#${pullRequest.number}`) + .join(" → "); + return `Run PR #${action.pr.number} in dev: ${action.pr.title}?\n\nThis runs the exact PR head ${action.pr.headRefOid?.slice(0, 8) ?? "shown in Delivery"}. Included layers: ${includedPullRequests}. It runs over Tailscale HTTPS without source watchers, using an isolated Dashboard database, a writable workspace snapshot, and an isolated scheduler/worker without host or backup jobs. It connects to the live production Gateway so chat and session changes can affect production data. The dev environment stops automatically after four hours.`; } case "preview-rebuild": { - return `Rebuild PR dev for #${action.pr.number}: ${action.pr.title}?\n\nThis replaces the running dev environment with the latest PR head while keeping the same isolation and live production Gateway connection. The rebuilt environment stops automatically after four hours.`; + const includedPullRequests = action.scope + .map((pullRequest) => `#${pullRequest.number}`) + .join(" → "); + return `Rebuild PR dev for #${action.pr.number}: ${action.pr.title}?\n\nThis replaces the running dev environment with exact PR head ${action.pr.headRefOid?.slice(0, 8) ?? "shown in Delivery"}. Included layers: ${includedPullRequests}. It keeps the same isolation and live production Gateway connection. The rebuilt environment stops automatically after four hours.`; } case "preview-stop": { const title = action.title ? `: ${action.title}` : ""; @@ -562,6 +634,12 @@ function actionMessage(action: Exclude) { case "reject": { return `Reject PR #${action.pr.number}: ${action.pr.title}?\n\nThis closes the PR with a dashboard rejection comment. It does not delete the branch.`; } + case "stack-create": { + const pullRequestNumbers = action.candidate.pullRequests + .map((pullRequest) => `#${pullRequest.number}`) + .join(" → "); + return `Create a GitHub stack from ${pullRequestNumbers}?\n\nThe existing pull requests will be linked from bottom to top. Their branches, commits, and review state are unchanged.`; + } case "deploy": { return `Deploy latest ${DEFAULT_BASE}?\n\nThis will sync ${DEFAULT_BASE}, publish an immutable release, atomically activate it, restart web and worker, and verify commit-bound readiness. A failed release is rolled back automatically.`; } @@ -579,12 +657,15 @@ function actionMessage(action: Exclude) { */ function actionResultMessage( message: string, - ...cleanupResults: Array<{ message: string } | undefined> + ...cleanupResults: Array<{ message: string } | { message: string }[] | undefined> ) { return [ message, ...cleanupResults - .filter((cleanup) => cleanup !== undefined) + .flatMap((cleanup) => { + if (cleanup === undefined) return []; + return Array.isArray(cleanup) ? cleanup : [cleanup]; + }) .map((cleanup) => cleanup.message), ].join("\n"); } @@ -674,6 +755,14 @@ function PullRequestCard({ {authorLabel(pr)} + {pr.stack ? ( + <> + Stack #{pr.stack.number} + + {pr.stack.position}/{pr.stack.size} + + + ) : undefined} {pr.mergeable || "mergeable unknown"} @@ -779,6 +868,7 @@ export function Delivery() { } = usePullRequestPreview(); const approvePullRequest = useApprovePullRequest(); const approvePullRequestReview = useApprovePullRequestReview(); + const createPullRequestStack = useCreatePullRequestStack(); const rejectPullRequest = useRejectPullRequest(); const updatePullRequestBranch = useUpdatePullRequestBranch(); const deployDashboard = useDeployDashboard(); @@ -791,6 +881,7 @@ export function Delivery() { const isActionPending = approvePullRequest.isPending || approvePullRequestReview.isPending || + createPullRequestStack.isPending || rejectPullRequest.isPending || updatePullRequestBranch.isPending || deployDashboard.isPending || @@ -811,8 +902,23 @@ export function Delivery() { number: previewStatus.number, title: previewStatus.title, }; - const miraPullRequests = pullRequests.filter((pr) => isMiraPullRequest(pr)); - const externalPullRequests = pullRequests.filter((pr) => !isMiraPullRequest(pr)); + const stackGroups = groupNativePullRequestStacks(pullRequests); + const unstackedPullRequests = pullRequests.filter( + (pullRequest) => pullRequest.stack === undefined + ); + const stackCandidates = derivePullRequestStackCandidates( + unstackedPullRequests, + DEFAULT_BASE + ); + const stackCandidateEntries = indexPullRequestStackCandidates(stackCandidates); + const standalonePullRequests = unstackedPullRequests.filter( + (pullRequest) => !stackCandidateEntries.has(pullRequest.number) + ); + const hasMiraPullRequests = pullRequests.some((pr) => isMiraPullRequest(pr)); + const miraPullRequests = standalonePullRequests.filter((pr) => isMiraPullRequest(pr)); + const externalPullRequests = standalonePullRequests.filter( + (pr) => !isMiraPullRequest(pr) + ); /** Performs confirm action. */ async function confirmAction(action: Exclude) { @@ -820,7 +926,23 @@ export function Delivery() { try { switch (action.type) { case "merge": { + const expectedHeadSha = action.pr.headRefOid; + if ( + typeof expectedHeadSha !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(expectedHeadSha) + ) { + throw new Error( + "Refresh Delivery before merging because the exact PR head is unavailable" + ); + } + const expectedStackHeads = expectedStackHeadsForMerge( + action.pr, + action.scope + ); const result = await approvePullRequest.mutateAsync({ + expectedHeadSha, + expectedStackHeads, + mergeStack: action.pr.stack !== undefined, number: action.pr.number, willDeploy: false, }); @@ -828,14 +950,32 @@ export function Delivery() { actionResultMessage( result.message, result.cleanup, - result.previewCleanup + result.cleanups, + result.previewCleanup, + result.previewCleanups ) ); break; } case "merge-deploy": { + const expectedHeadSha = action.pr.headRefOid; + if ( + typeof expectedHeadSha !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(expectedHeadSha) + ) { + throw new Error( + "Refresh Delivery before merging because the exact PR head is unavailable" + ); + } + const expectedStackHeads = expectedStackHeadsForMerge( + action.pr, + action.scope + ); const result = await approvePullRequest.mutateAsync({ + expectedHeadSha, + expectedStackHeads, + mergeStack: action.pr.stack !== undefined, number: action.pr.number, willDeploy: true, }); @@ -846,7 +986,9 @@ export function Delivery() { actionResultMessage( message, result.cleanup, - result.previewCleanup + result.cleanups, + result.previewCleanup, + result.previewCleanups ) ); break; @@ -864,7 +1006,17 @@ export function Delivery() { case "preview-rebuild": case "preview-start": { const isRebuild = action.type === "preview-rebuild"; + const expectedHeadSha = action.pr.headRefOid; + if ( + typeof expectedHeadSha !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(expectedHeadSha) + ) { + throw new Error( + "Refresh Delivery before starting dev because the exact PR head is unavailable" + ); + } const preview = await startPullRequestPreview.mutateAsync({ + expectedHeadSha, number: action.pr.number, }); let resultMessage: string; @@ -904,6 +1056,16 @@ export function Delivery() { break; } + case "stack-create": { + const result = await createPullRequestStack.mutateAsync({ + pullRequests: action.candidate.pullRequests.map( + (pullRequest) => pullRequest.number + ), + }); + setLastResult(result.message); + break; + } + case "deploy": { const result = await deployDashboard.mutateAsync(); setLastResult(result?.deployment?.note ?? "Deploy scheduled"); @@ -932,7 +1094,10 @@ export function Delivery() { * Builds trusted PR dev status and controls for an eligible pull request. * @returns Built trusted PR dev status and controls for an eligible pull request. */ - function pullRequestPreviewActions(pr: PullRequestSummary) { + function pullRequestPreviewActions( + pr: PullRequestSummary, + scope: PullRequestSummary[] + ) { if (pr.previewEligible !== true) { return { blockedMessage: undefined, controls: undefined }; } @@ -999,6 +1164,7 @@ export function Delivery() { onClick={() => setPendingAction({ pr, + scope, type: isRebuildDevelopment ? "preview-rebuild" : "preview-start", @@ -1049,38 +1215,113 @@ export function Delivery() { * @returns Rendered merge controls for a pull request. */ function renderPullRequestActions(pr: PullRequestSummary) { - const previewActions = pullRequestPreviewActions(pr); - const isChecksPassed = hasPullRequestChecksPassed(pr.statusCheckRollup); - const isReviewApproved = isPullRequestReviewApproved(pr); - const isMergeBlocked = isGithubMergeBlocked(pr); + const stackCandidateEntry = stackCandidateEntries.get(pr.number); + const previewScope = stackCandidateEntry + ? stackCandidateEntry.candidate.pullRequests.slice( + 0, + stackCandidateEntry.position + ) + : pullRequestStackMergeGroup(pr, pullRequests); + const previewActions = pullRequestPreviewActions(pr, previewScope); + if (stackCandidateEntry) { + return ( + <> + {previewActions.blockedMessage ? ( +

+ {previewActions.blockedMessage} +

+ ) : undefined} + {previewActions.controls} +

+ This is layer {stackCandidateEntry.position}/ + {stackCandidateEntry.candidate.pullRequests.length} in an unlinked + GitHub stack candidate. Create the stack above before reviewing, + merging, or rejecting it from Delivery. +

+ + ); + } + if ( + !pr.stack && + pr.isCrossRepository !== true && + pr.headRefName !== DEFAULT_BASE && + unstackedPullRequests.some( + (pullRequest) => + pullRequest.number !== pr.number && + pullRequest.baseRefName === pr.headRefName + ) + ) { + return ( +

+ This PR has an ambiguous or incomplete dependent chain. Restructure + the branches into one linear candidate before managing it from + Delivery. +

+ ); + } + if (pr.baseRefName !== DEFAULT_BASE && !pr.stack) { + return ( +

+ This dependent PR targets{" "} + {pr.baseRefName}. + Link its complete linear chain as a GitHub stack before managing it + from Delivery. +

+ ); + } + if (pr.stack && pr.stack.baseRefName !== DEFAULT_BASE) { + return ( +

+ GitHub stack #{pr.stack.number} targets{" "} + + {pr.stack.baseRefName} + + . Only {DEFAULT_BASE}-rooted stacks can be managed from Delivery. +

+ ); + } + const mergeGroup = pullRequestStackMergeGroup(pr, pullRequests); + const draftPullRequest = mergeGroup.find((pullRequest) => pullRequest.isDraft); + const checksBlockedPullRequest = mergeGroup.find( + (pullRequest) => !hasPullRequestChecksPassed(pullRequest.statusCheckRollup) + ); + const reviewBlockedPullRequest = mergeGroup.find( + (pullRequest) => !isPullRequestReviewApproved(pullRequest) + ); + const githubBlockedPullRequest = mergeGroup.find((pullRequest) => + isGithubMergeBlocked(pullRequest) + ); + const missingExpectedHeadPullRequest = mergeGroup.find( + (pullRequest) => + typeof pullRequest.headRefOid !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(pullRequest.headRefOid) + ); const canUpdateBranch = + !pr.stack && pr.baseRefName === DEFAULT_BASE && isPullRequestBranchBehind(pr) && !hasPullRequestConflicts(pr); const isMergeDisabled = isActionPending || isProductionActionBlocked || - pr.isDraft || - !isChecksPassed || - !isReviewApproved || - isMergeBlocked; + draftPullRequest !== undefined || + checksBlockedPullRequest !== undefined || + reviewBlockedPullRequest !== undefined || + githubBlockedPullRequest !== undefined || + missingExpectedHeadPullRequest !== undefined; let mergeDisabledReason: string | undefined; - if (pr.isDraft) { - mergeDisabledReason = - "Draft pull requests cannot be merged from the dashboard"; - } else if (isChecksPassed) { - if (isReviewApproved) { - if (isMergeBlocked) { - mergeDisabledReason = - "GitHub reports this pull request is blocked from merging"; - } else if (isProductionActionBlocked) { - mergeDisabledReason = productionActionBlockedMessage; - } - } else { - mergeDisabledReason = "Approve the PR before merging from the dashboard"; - } - } else { - mergeDisabledReason = "CI checks must pass before merging from the dashboard"; + if (draftPullRequest) { + mergeDisabledReason = `PR #${draftPullRequest.number} is a draft`; + } else if (checksBlockedPullRequest) { + mergeDisabledReason = `CI checks must pass on PR #${checksBlockedPullRequest.number} before merging`; + } else if (reviewBlockedPullRequest) { + mergeDisabledReason = `Approve PR #${reviewBlockedPullRequest.number} before merging`; + } else if (githubBlockedPullRequest) { + mergeDisabledReason = `GitHub reports PR #${githubBlockedPullRequest.number} is blocked from merging`; + } else if (missingExpectedHeadPullRequest) { + mergeDisabledReason = `Refresh Delivery before merging because the exact head for PR #${missingExpectedHeadPullRequest.number} is unavailable`; + } else if (isProductionActionBlocked) { + mergeDisabledReason = productionActionBlockedMessage; } const mergeDisabledReasonId = mergeDisabledReason ? `pr-${pr.number}-merge-disabled-reason` @@ -1143,30 +1384,45 @@ export function Delivery() { {previewActions.controls} - + {pr.stack ? ( +

+ Reject is unavailable because closing one member leaves a blocker + in the GitHub stack. Restructure or unstack it on GitHub first. +

+ ) : ( + + )} ); } @@ -1349,7 +1605,154 @@ export function Delivery() { ) : undefined} - {pullRequests.length > 0 && miraPullRequests.length === 0 ? ( + {stackCandidates.length > 0 ? ( +
+
+ + count + candidate.pullRequests.length, + 0 + )} + badgeVariant="warning" + /> +

+ These existing PR chains are linear but not + yet linked as GitHub stacks. +

+
+
+ {stackCandidates.map((candidate) => { + const numbers = candidate.pullRequests + .map( + (pullRequest) => + `#${pullRequest.number}` + ) + .join(" → "); + return ( +
+
+
+
+ {numbers} +
+

+ Bottom targets{" "} + + { + candidate.baseRefName + } + + ; each next PR targets the + branch below it. +

+
+ +
+ {candidate.pullRequests.map((pr) => ( + + ))} +
+ ); + })} +
+
+ ) : undefined} + + {stackGroups.length > 0 ? ( +
+
+ + count + group.pullRequests.length, + 0 + )} + badgeVariant="info" + /> +

+ Choose any layer to submit it and every open + PR below it as one merge group. Choosing the + top submits the full remaining stack. +

+
+
+ {stackGroups.map((group) => { + const firstPullRequest = + group.pullRequests[0]; + const stack = firstPullRequest?.stack; + if (!stack) return null; + return ( +
+
+
+

+ Stack #{group.number} +

+

+ { + group.pullRequests + .length + }{" "} + open of {stack.size} total + · base{" "} + + {stack.baseRefName} + +

+
+ + Bottom → top + +
+ {group.pullRequests.map((pr) => ( + + ))} +
+ ); + })} +
+
+ ) : undefined} + + {pullRequests.length > 0 && !hasMiraPullRequests ? ( No Mira-authored PRs waiting

@@ -1371,8 +1774,9 @@ export function Delivery() { badgeVariant="info" />

- These can be merged, rejected, or merged and - deployed from the dashboard. + Standalone main PRs use the existing single-PR + flow. Unresolved dependent PRs stay read-only + until linked as a stack.

@@ -1399,8 +1803,10 @@ export function Delivery() { badgeVariant="default" />

- These can be merged after the same review, CI, + Standalone changes use the same review, CI, and checkout gates as Mira-authored PRs. + Unresolved dependent PRs stay read-only until + linked as a stack.

diff --git a/frontend/src/test/contracts.test.ts b/frontend/src/test/contracts.test.ts index 918ae825c..58ea842fe 100644 --- a/frontend/src/test/contracts.test.ts +++ b/frontend/src/test/contracts.test.ts @@ -10,7 +10,11 @@ import { } from "../../../contracts/accountSecurity"; import { parseApiErrorResponse } from "../../../contracts/apiErrors"; import { parseBackupStatusResponse } from "../../../contracts/backups"; -import { parsePullRequestApproveRequest } from "../../../contracts/delivery"; +import { + parsePullRequestApproveRequest, + parsePullRequestPreviewStartRequest, + parsePullRequestStackCreateRequest, +} from "../../../contracts/delivery"; import { parseExecRequest } from "../../../contracts/exec"; import { parseFileContent, parseFilesResponse } from "../../../contracts/files"; import { @@ -167,6 +171,54 @@ describe("shared runtime contracts", () => { } }); + it("validates exact-head stack merge and linear stack creation requests", () => { + expect( + parsePullRequestApproveRequest({ + deploy: true, + expectedHeadSha: "a".repeat(40), + expectedStackHeads: [ + { headSha: "9".repeat(40), number: 352 }, + { headSha: "a".repeat(40), number: 353 }, + ], + mergeStack: true, + }) + ).toEqual({ + deploy: true, + expectedHeadSha: "a".repeat(40), + expectedStackHeads: [ + { headSha: "9".repeat(40), number: 352 }, + { headSha: "a".repeat(40), number: 353 }, + ], + mergeStack: true, + }); + expect(parsePullRequestStackCreateRequest({ pullRequests: [352, 353] })).toEqual({ + pullRequests: [352, 353], + }); + expect( + parsePullRequestPreviewStartRequest({ + expectedHeadSha: "b".repeat(40), + }) + ).toEqual({ expectedHeadSha: "b".repeat(40) }); + expect(() => + parsePullRequestStackCreateRequest({ pullRequests: [352] }) + ).toThrow(); + expect(() => + parsePullRequestApproveRequest({ + expectedHeadSha: "not-a-full-sha", + mergeStack: true, + }) + ).toThrow(); + expect(() => + parsePullRequestApproveRequest({ + expectedHeadSha: "a".repeat(40), + expectedStackHeads: [{ headSha: "short", number: 352 }], + mergeStack: true, + }) + ).toThrow(); + expect(() => parsePullRequestApproveRequest({ deploy: false })).toThrow(); + expect(() => parsePullRequestPreviewStartRequest({})).toThrow(); + }); + it("validates exec and scheduled-job transport shapes before service logic", () => { expect( parseExecRequest({ diff --git a/frontend/src/test/frontendBehavior.test.tsx b/frontend/src/test/frontendBehavior.test.tsx index c19a0e6d5..2653ce29c 100644 --- a/frontend/src/test/frontendBehavior.test.tsx +++ b/frontend/src/test/frontendBehavior.test.tsx @@ -140,6 +140,7 @@ import { deliveryKeys, useApprovePullRequest, useApprovePullRequestReview, + useCreatePullRequestStack, useDashboardDeployments, useDashboardReleaseStatus, useDeployDashboard, @@ -5091,10 +5092,23 @@ describe("Mira Dashboard frontend behavior", () => { if (url === "/api/pull-requests/189/approve" && method === "POST") { expect(JSON.parse(requestBodyText(init?.body))).toEqual({ deploy: true, + expectedHeadSha: "a".repeat(40), + expectedStackHeads: [ + { headSha: "9".repeat(40), number: 188 }, + { headSha: "a".repeat(40), number: 189 }, + ], + mergeStack: true, }); return Response.json({ isOk: true, message: "approved" }); } + if (url === "/api/pull-requests/stacks" && method === "POST") { + expect(JSON.parse(requestBodyText(init?.body))).toEqual({ + pullRequests: [188, 189], + }); + return Response.json({ isOk: true, message: "stack created" }); + } + if ( url === "/api/pull-requests/189/review-approval" && method === "POST" @@ -5169,7 +5183,9 @@ describe("Mira Dashboard frontend behavior", () => { } if (url === "/api/pull-requests/189/preview/start" && method === "POST") { - expect(JSON.parse(requestBodyText(init?.body))).toEqual({}); + expect(JSON.parse(requestBodyText(init?.body))).toEqual({ + expectedHeadSha: "a".repeat(40), + }); return Response.json({ isOk: true, preview: { @@ -5201,10 +5217,22 @@ describe("Mira Dashboard frontend behavior", () => { useApprovePullRequest() ); await approvePullRequest.result.current.mutateAsync({ + expectedHeadSha: "a".repeat(40), + expectedStackHeads: [ + { headSha: "9".repeat(40), number: 188 }, + { headSha: "a".repeat(40), number: 189 }, + ], + mergeStack: true, number: 189, willDeploy: true, }); + const createStack = renderHookWithQueryClient(() => useCreatePullRequestStack()); + const createStackResponse = await createStack.result.current.mutateAsync({ + pullRequests: [188, 189], + }); + expect(createStackResponse).toMatchObject({ message: "stack created" }); + const approveReview = renderHookWithQueryClient(() => useApprovePullRequestReview() ); @@ -5241,7 +5269,10 @@ describe("Mira Dashboard frontend behavior", () => { useStartPullRequestPreview() ); expect( - startPreview.result.current.mutateAsync({ number: 189 }) + startPreview.result.current.mutateAsync({ + expectedHeadSha: "a".repeat(40), + number: 189, + }) ).resolves.toMatchObject({ number: 189, status: "running", diff --git a/frontend/src/test/pageBehavior.test.tsx b/frontend/src/test/pageBehavior.test.tsx index f78dda779..4d5ae45d5 100644 --- a/frontend/src/test/pageBehavior.test.tsx +++ b/frontend/src/test/pageBehavior.test.tsx @@ -1890,6 +1890,7 @@ function apiResponse(url: string, method: string, init?: RequestInit) { title: "Expand backend coverage", url: "https://github.com/rajohan/Mira-Dashboard/pull/190", headRefName: "test/backend", + headRefOid: "a".repeat(40), baseRefName: "main", author: { login: "mira-2026" }, createdAt: "2026-06-24T08:00:00.000Z", @@ -1993,7 +1994,11 @@ function apiResponse(url: string, method: string, init?: RequestInit) { } if (method === "POST" && url === "/api/pull-requests/190/approve") { - expect(parseRequestBody(init)).toEqual({ deploy: false }); + expect(parseRequestBody(init)).toEqual({ + deploy: false, + expectedHeadSha: "a".repeat(40), + mergeStack: false, + }); return Response.json({ isOk: true, message: "Merged PR #190", @@ -3148,6 +3153,441 @@ describe("Mira Dashboard pages", () => { view.queryClient.clear(); }); + it("shows dependent PR chains and creates GitHub stacks bottom-to-top", async () => { + const user = userEvent.setup(); + const defaultFetch = globalThis.fetch; + const stackCreateRequests: unknown[] = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + const method = init?.method ?? "GET"; + if (url === "/api/pull-requests") { + return Promise.resolve( + Response.json({ + pullRequests: [ + { + author: { login: "mira-2026" }, + baseRefName: "main", + createdAt: "2026-07-30T07:00:00.000Z", + headRefName: "feat/models", + headRefOid: "c".repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 370, + previewEligible: true, + reviewDecision: "APPROVED", + reviewerApproved: true, + statusCheckRollup: [{ status: "SUCCESS" }], + title: "Add chat models", + updatedAt: "2026-07-30T08:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/370", + }, + { + author: { login: "mira-2026" }, + baseRefName: "feat/models", + createdAt: "2026-07-30T08:00:00.000Z", + headRefName: "feat/chat-ui", + headRefOid: "d".repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 371, + previewEligible: true, + reviewDecision: "APPROVED", + reviewerApproved: true, + statusCheckRollup: [{ status: "SUCCESS" }], + title: "Add chat UI", + updatedAt: "2026-07-30T09:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/371", + }, + ], + }) + ); + } + if (method === "POST" && url === "/api/pull-requests/stacks") { + stackCreateRequests.push(parseRequestBody(init)); + return Promise.resolve( + Response.json({ + isOk: true, + message: "GitHub stack #372 created with 2 PRs", + }) + ); + } + return defaultFetch(input, init); + }), + writable: true, + }); + + const view = renderPage(createElement(Delivery)); + + const candidates = await screen.findByRole("region", { + name: "GitHub stack candidates", + }); + expect(within(candidates).getByText("#370 → #371")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Add chat models" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Add chat UI" })).toBeInTheDocument(); + expect( + screen.getAllByText(/in an unlinked GitHub stack candidate/u) + ).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Merge only" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); + expect( + screen.getAllByText(/before reviewing, merging, or rejecting/u) + ).toHaveLength(2); + const runInDevButtons = screen.getAllByRole("button", { name: "Run in dev" }); + expect(runInDevButtons).toHaveLength(2); + await user.click(runInDevButtons[1] as HTMLButtonElement); + const previewDialog = screen.getByRole("dialog", { name: "Run PR in dev" }); + expect( + within(previewDialog).getByText(/Included layers: #370 → #371/u) + ).toBeInTheDocument(); + expect( + within(previewDialog).getByText(/exact PR head dddddddd/u) + ).toBeInTheDocument(); + await user.click(within(previewDialog).getByRole("button", { name: "Cancel" })); + + await user.click( + within(candidates).getByRole("button", { name: "Create stack" }) + ); + expect( + screen.getByRole("heading", { name: "Create GitHub stack" }) + ).toBeInTheDocument(); + const dialog = screen.getByRole("dialog", { name: "Create GitHub stack" }); + expect(within(dialog).getByText(/#370 → #371/u)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Create GitHub stack" })); + await waitFor(() => { + expect( + screen.getByText("GitHub stack #372 created with 2 PRs") + ).toBeInTheDocument(); + }); + expect(stackCreateRequests).toEqual([{ pullRequests: [370, 371] }]); + + view.unmount(); + view.queryClient.clear(); + }); + + it("keeps standalone fork controls available when its head matches main", async () => { + const defaultFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (requestUrl(input) === "/api/pull-requests") { + return Promise.resolve( + Response.json({ + pullRequests: [ + { + author: { login: "mira-2026" }, + baseRefName: "main", + createdAt: "2026-07-30T07:00:00.000Z", + headRefName: "main", + headRefOid: "e".repeat(40), + isCrossRepository: true, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 372, + canReviewerApprove: true, + previewEligible: false, + reviewDecision: "REVIEW_REQUIRED", + reviewerApproved: false, + statusCheckRollup: [{ status: "SUCCESS" }], + title: "Fork default branch", + updatedAt: "2026-07-30T08:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/372", + }, + { + author: { login: "mira-2026" }, + baseRefName: "main", + createdAt: "2026-07-30T08:00:00.000Z", + headRefName: "ordinary-root", + headRefOid: "f".repeat(40), + isCrossRepository: false, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 373, + previewEligible: false, + reviewDecision: "APPROVED", + reviewerApproved: true, + statusCheckRollup: [{ status: "SUCCESS" }], + title: "Ordinary root PR", + updatedAt: "2026-07-30T09:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/373", + }, + ], + }) + ); + } + return defaultFetch(input, init); + }), + writable: true, + }); + + const view = renderPage(createElement(Delivery)); + + expect(await screen.findByText("Fork default branch")).toBeInTheDocument(); + expect(screen.queryByText(/ambiguous or incomplete dependent chain/u)).toBeNull(); + expect(screen.getByRole("button", { name: "Approve PR" })).toBeEnabled(); + expect(screen.getAllByRole("button", { name: "Merge only" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Reject" })).toHaveLength(2); + + view.unmount(); + view.queryClient.clear(); + }); + + it("groups native stacks and merges through the selected layer with cleanup details", async () => { + const user = userEvent.setup(); + const defaultFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + const method = init?.method ?? "GET"; + if (url === "/api/pull-requests") { + return Promise.resolve( + Response.json({ + pullRequests: [ + { + additions: 8, + author: { login: "mira-2026" }, + baseRefName: "main", + body: "Canonical foundation", + canReviewerApprove: false, + changedFiles: 2, + createdAt: "2026-07-30T07:00:00.000Z", + deletions: 1, + headRefName: "feat/canonical-chat-v2", + headRefOid: "a".repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 352, + previewEligible: true, + reviewDecision: "APPROVED", + reviewerApproved: true, + stack: { + baseRefName: "main", + number: 360, + position: 1, + size: 2, + }, + statusCheckRollup: [ + { + conclusion: "SUCCESS", + status: "COMPLETED", + }, + ], + title: "Canonical chat foundation", + updatedAt: "2026-07-30T08:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/352", + }, + { + additions: 14, + author: { login: "mira-2026" }, + baseRefName: "feat/canonical-chat-v2", + body: "Stacked projection", + canReviewerApprove: false, + changedFiles: 4, + createdAt: "2026-07-30T08:00:00.000Z", + deletions: 3, + headRefName: "feat/chat-state-machine-matrix", + headRefOid: "b".repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 353, + previewEligible: true, + reviewDecision: "APPROVED", + reviewerApproved: true, + stack: { + baseRefName: "main", + number: 360, + position: 2, + size: 2, + }, + statusCheckRollup: [ + { + conclusion: "SUCCESS", + status: "COMPLETED", + }, + ], + title: "Canonical state machine", + updatedAt: "2026-07-30T09:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/353", + }, + ], + }) + ); + } + if (method === "POST" && url === "/api/pull-requests/353/approve") { + expect(parseRequestBody(init)).toEqual({ + deploy: false, + expectedHeadSha: "b".repeat(40), + expectedStackHeads: [ + { headSha: "a".repeat(40), number: 352 }, + { headSha: "b".repeat(40), number: 353 }, + ], + mergeStack: true, + }); + return Promise.resolve( + Response.json({ + cleanups: [ + { + branch: "feat/canonical-chat-v2", + message: + "Removed local worktree for feat/canonical-chat-v2", + status: "removed", + }, + { + branch: "feat/chat-state-machine-matrix", + message: + "Removed local worktree for feat/chat-state-machine-matrix", + status: "removed", + }, + ], + isOk: true, + mergeStatus: "merged", + message: "Stack #360 merged through PR #353 (2 PRs)", + previewCleanups: [ + { + message: "No managed PR dev data found for #352", + number: 352, + status: "skipped", + }, + { + message: "No managed PR dev data found for #353", + number: 353, + status: "skipped", + }, + ], + }) + ); + } + return defaultFetch(input, init); + }), + writable: true, + }); + + const view = renderPage(createElement(Delivery)); + + await waitFor(() => { + expect( + screen.getByRole("link", { name: "Canonical state machine" }) + ).toBeInTheDocument(); + }); + expect(screen.getByRole("region", { name: "GitHub stacks" })).toBeInTheDocument(); + const stack = screen.getByLabelText("GitHub stack #360"); + expect(within(stack).getByText("Bottom → top")).toBeInTheDocument(); + expect(within(stack).getByText("1/2")).toBeInTheDocument(); + expect(within(stack).getByText("2/2")).toBeInTheDocument(); + expect( + within(stack).getByRole("button", { + name: "Merge stack through #353", + }) + ).toBeEnabled(); + expect(within(stack).queryByRole("button", { name: "Reject" })).toBeNull(); + expect( + within(stack).getAllByText(/closing one member leaves a blocker/u) + ).toHaveLength(2); + const stackRunInDevButtons = within(stack).getAllByRole("button", { + name: "Run in dev", + }); + expect(stackRunInDevButtons).toHaveLength(2); + await user.click(stackRunInDevButtons[1] as HTMLButtonElement); + const previewDialog = screen.getByRole("dialog", { name: "Run PR in dev" }); + expect( + within(previewDialog).getByText(/Included layers: #352 → #353/u) + ).toBeInTheDocument(); + await user.click(within(previewDialog).getByRole("button", { name: "Cancel" })); + + await user.click( + within(stack).getByRole("button", { + name: "Merge stack through #353", + }) + ); + expect(screen.getByRole("heading", { name: "Merge stack" })).toBeInTheDocument(); + expect(screen.getByText(/one all-or-nothing merge group/i)).toBeInTheDocument(); + expect( + screen.getByText(/Included exact heads: #352 aaaaaaaa → #353 bbbbbbbb/u) + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Merge stack" })); + await waitFor(() => { + expect( + screen.getByText(/Stack #360 merged through PR #353 \(2 PRs\)/u) + ).toBeInTheDocument(); + expect( + screen.getByText( + /Removed local worktree for feat\/chat-state-machine-matrix/u + ) + ).toBeInTheDocument(); + }); + + view.unmount(); + view.queryClient.clear(); + }); + + it("keeps native stacks outside main read-only", async () => { + const defaultFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (requestUrl(input) === "/api/pull-requests") { + return Promise.resolve( + Response.json({ + pullRequests: [ + { + author: { login: "mira-2026" }, + baseRefName: "develop", + canReviewerApprove: true, + createdAt: "2026-07-30T08:00:00.000Z", + headRefName: "feat/develop-stack", + headRefOid: "c".repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 364, + previewEligible: true, + reviewDecision: "REVIEW_REQUIRED", + stack: { + baseRefName: "develop", + number: 361, + position: 1, + size: 1, + }, + statusCheckRollup: [ + { + conclusion: "SUCCESS", + status: "COMPLETED", + }, + ], + title: "Develop-only stack", + updatedAt: "2026-07-30T09:00:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/364", + }, + ], + }) + ); + } + return defaultFetch(input, init); + }), + writable: true, + }); + + const view = renderPage(createElement(Delivery)); + const stack = await screen.findByLabelText("GitHub stack #361"); + expect( + within(stack).getByText(/Only main-rooted stacks can be managed/u) + ).toBeInTheDocument(); + expect(within(stack).queryByRole("button", { name: "Approve PR" })).toBeNull(); + expect(within(stack).queryByRole("button", { name: "Run in dev" })).toBeNull(); + expect(within(stack).queryByRole("button", { name: /Merge/u })).toBeNull(); + + view.unmount(); + view.queryClient.clear(); + }); + it("keeps PR dev status messages ahead of pull request action buttons", async () => { Object.defineProperty(globalThis, "fetch", { configurable: true, @@ -3337,7 +3777,9 @@ describe("Mira Dashboard pages", () => { method === "POST" && url === "/api/pull-requests/335/preview/start" ) { - expect(parseRequestBody(init)).toEqual({}); + expect(parseRequestBody(init)).toEqual({ + expectedHeadSha: "a".repeat(40), + }); preview = { commitSha: "a".repeat(40), number: 335, @@ -3449,6 +3891,9 @@ describe("Mira Dashboard pages", () => { method === "POST" && url === "/api/pull-requests/335/preview/start" ) { + expect(parseRequestBody(init)).toEqual({ + expectedHeadSha: "a".repeat(40), + }); startCalls += 1; preview = { ...preview, @@ -3485,7 +3930,8 @@ describe("Mira Dashboard pages", () => { expect( screen.getByRole("heading", { name: "Rebuild PR dev" }) ).toBeInTheDocument(); - expect(screen.getByText(/latest PR head/u)).toBeInTheDocument(); + expect(screen.getByText(/exact PR head aaaaaaaa/u)).toBeInTheDocument(); + expect(screen.getByText(/Included layers: #335/u)).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Rebuild PR dev" })); await waitFor(() => { diff --git a/frontend/src/test/pullRequestStacks.test.ts b/frontend/src/test/pullRequestStacks.test.ts new file mode 100644 index 000000000..5abc46ae9 --- /dev/null +++ b/frontend/src/test/pullRequestStacks.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "bun:test"; + +import type { PullRequestSummary } from "../../../contracts/delivery"; +import { + derivePullRequestStackCandidates, + groupNativePullRequestStacks, + indexPullRequestStackCandidates, + pullRequestStackMergeGroup, +} from "../components/features/delivery/pullRequestStacks"; + +function pullRequest( + number: number, + headRefName: string, + baseRefName: string, + overrides: Partial = {} +): PullRequestSummary { + return { + baseRefName, + createdAt: `2026-07-30T10:${String(number).padStart(2, "0")}:00.000Z`, + headRefName, + headRefOid: String(number).padStart(40, "0"), + isDraft: false, + number, + title: `PR ${number}`, + updatedAt: `2026-07-30T11:${String(number).padStart(2, "0")}:00.000Z`, + url: `https://github.test/pull/${number}`, + ...overrides, + }; +} + +describe("Delivery pull request stacks", () => { + it("derives only complete unambiguous linear stack candidates", () => { + const chain = [ + pullRequest(1, "models", "main"), + pullRequest(2, "api", "models"), + pullRequest(3, "ui", "api"), + ]; + const candidates = derivePullRequestStackCandidates( + [ + ...chain, + pullRequest(4, "parallel-a", "models"), + pullRequest(5, "parallel-b", "models"), + pullRequest(6, "already-stacked", "main", { + stack: { + baseRefName: "main", + number: 42, + position: 1, + size: 2, + }, + }), + ], + "main" + ); + + expect(candidates).toEqual([]); + const linearCandidates = derivePullRequestStackCandidates(chain, "main"); + expect(linearCandidates).toHaveLength(1); + expect(linearCandidates[0]?.pullRequests.map((entry) => entry.number)).toEqual([ + 1, 2, 3, + ]); + expect(indexPullRequestStackCandidates(linearCandidates).get(3)).toMatchObject({ + position: 3, + }); + }); + + it("excludes fork pull requests from inferred stack candidates", () => { + const candidates = derivePullRequestStackCandidates( + [ + pullRequest(10, "shared-branch", "main", { + isCrossRepository: true, + }), + pullRequest(11, "fork-dependent", "shared-branch"), + pullRequest(20, "models", "main"), + pullRequest(21, "api", "models"), + ], + "main" + ); + + expect(candidates).toHaveLength(1); + expect(candidates[0]?.pullRequests.map((entry) => entry.number)).toEqual([ + 20, 21, + ]); + }); + + it("groups native stacks bottom-to-top and merges only through the selected layer", () => { + const stack360 = [ + pullRequest(353, "state-machine", "canonical", { + stack: { + baseRefName: "main", + number: 360, + position: 2, + size: 3, + }, + }), + pullRequest(352, "canonical", "main", { + stack: { + baseRefName: "main", + number: 360, + position: 1, + size: 3, + }, + }), + pullRequest(354, "contract", "state-machine", { + stack: { + baseRefName: "main", + number: 360, + position: 3, + size: 3, + }, + }), + ]; + const otherStack = [ + pullRequest(400, "other-bottom", "main", { + updatedAt: "2026-07-30T09:00:00.000Z", + stack: { + baseRefName: "main", + number: 401, + position: 1, + size: 1, + }, + }), + ]; + + const groups = groupNativePullRequestStacks([...otherStack, ...stack360]); + expect(groups.map((group) => group.number)).toEqual([360, 401]); + expect(groups[0]?.pullRequests.map((entry) => entry.number)).toEqual([ + 352, 353, 354, + ]); + expect( + pullRequestStackMergeGroup(stack360[0] as PullRequestSummary, stack360).map( + (entry) => entry.number + ) + ).toEqual([352, 353]); + }); +});