Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions backend/src/routes/pullRequestRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,6 +40,7 @@ import {
runPullRequestBranchUpdate,
runPullRequestRejection,
runPullRequestReviewApproval,
runPullRequestStackCreation,
validatePrNumber,
} from "../services/pullRequests.ts";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
);
Expand Down
19 changes: 13 additions & 6 deletions backend/src/services/pullRequestPreviewHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ const SAFE_INSTALL_ENVIRONMENT_KEYS = [
] as const;

export interface PullRequestPreviewCandidate {
authorLogin?: string;
baseRefName: string;
authorLogins: Array<string | undefined>;
commitSha: string;
number: number;
rootBaseRefName: string;
title: string;
}

Expand Down Expand Up @@ -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 }
);
}
Expand Down
44 changes: 37 additions & 7 deletions backend/src/services/pullRequestPreviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import {
isDashboardPullRequestOpen,
listDashboardPullRequests,
pullRequestPreviewScope,
validatePullRequestPreviewScope,
validatePrNumber,
} from "./pullRequests.ts";
import {
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -88,7 +97,17 @@ async function findPullRequest(number: number): Promise<PullRequestPreviewCandid
statusCode: 404,
});
}
return pullRequestPreviewCandidate(pullRequest);
const scope = pullRequestPreviewScope(pullRequest, pullRequests);
if (!scope) {
throw Object.assign(
new Error(
`PR #${number} is not part of a main-rooted GitHub stack or linear stack candidate`
),
{ statusCode: 409 }
);
}
await validatePullRequestPreviewScope(pullRequest, scope);
return pullRequestPreviewCandidate(pullRequest, scope);
}

/**
Expand Down Expand Up @@ -261,14 +280,25 @@ export async function reconcileClosedPullRequestPreview(
/**
* Queues one managed preview startup in the dedicated production worker.
* @param number Number value.
* @param expectedHeadSha Exact pull request head confirmed by the user.
* @returns Promise resolving to the prepare and start pull request preview result.
*/
export async function prepareAndStartPullRequestPreview(
number: number
number: number,
expectedHeadSha: string
): Promise<PullRequestPreviewStatus> {
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) &&
Expand Down Expand Up @@ -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",
Expand Down
Loading