diff --git a/docs/deployments/github.mdx b/docs/deployments/github.mdx index 75c692aa..2a3ba80a 100644 --- a/docs/deployments/github.mdx +++ b/docs/deployments/github.mdx @@ -21,10 +21,12 @@ Techulus Cloud integrates with GitHub through a [GitHub App](https://docs.github The GitHub App needs these repository permissions: - **Contents:** Read -- **Pull requests:** Read +- **Pull requests:** Read and write - **Deployments:** Read and write Subscribe the app to both the **Push** and **Pull request** webhook events. +If you increase permissions for an existing app, approve the new permissions on +each installation before enabling previews. ## Connecting a Repository @@ -89,6 +91,10 @@ rechecks previews in case a webhook was missed. Generated hosts use the normal HTTP-01 certificate path, so installations with high pull-request volume should monitor their certificate authority's issuance limits. +Each preview service maintains one status comment on the pull request. The +comment shows the current deployment status and preview URL. Status and commit +updates replace the same comment instead of adding new comments. + ## Build Process Agents build images using one of two methods: diff --git a/docs/installation.mdx b/docs/installation.mdx index 5959d27f..6ed632e0 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -297,10 +297,11 @@ migrations from every replica. | `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64-encoded) | | `GITHUB_WEBHOOK_SECRET` | Webhook secret | -Configure the app with Contents read, Pull requests read, and Deployments write -repository permissions. Subscribe it to Push and Pull request events. Pull -request previews use the Automatic Subdomain Domain setting and its wildcard DNS -record when configured. Without it, previews are created without public URLs. +Configure the app with Contents read, Pull requests read and write, and +Deployments read and write repository permissions. Subscribe it to Push and +Pull request events. Pull request previews use the Automatic Subdomain Domain +setting and its wildcard DNS record when configured. Without it, previews are +created without public URLs. ## Generating Secrets diff --git a/web/lib/github.ts b/web/lib/github.ts index 70d92909..87b2a2e5 100644 --- a/web/lib/github.ts +++ b/web/lib/github.ts @@ -361,6 +361,97 @@ export async function getGitHubPullRequest( return mapGitHubPullRequest(pullRequest); } +export async function upsertGitHubPullRequestComment( + installationId: number, + repoFullName: string, + pullRequestNumber: number, + marker: string, + content: string, +): Promise { + validateRepoFullName(repoFullName); + if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) { + throw new Error("Invalid pull request number"); + } + const markerContent = marker.slice("".length); + if ( + !marker.startsWith("") || + !markerContent || + marker.includes("\r") || + marker.includes("\n") + ) { + throw new Error("Invalid pull request comment marker"); + } + const body = `${marker}\n${content.trim()}`; + if (body.length > 65_536) { + throw new Error("Pull request comment is too long"); + } + + const token = await getInstallationToken(installationId); + const headers = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }; + let existingCommentId: number | null = null; + for (let page = 1; ; page++) { + const response = await fetch( + `https://api.github.com/repos/${repoFullName}/issues/${pullRequestNumber}/comments?per_page=100&page=${page}`, + { headers }, + ); + if (!response.ok) { + const detail = await response.text(); + throw new GitHubApiError( + `Failed to list pull request comments (${response.status}): ${detail || response.statusText}`, + response.status, + ); + } + const comments = (await response.json()) as unknown; + if (!Array.isArray(comments)) { + throw new Error("GitHub returned invalid pull request comments"); + } + const existing = comments.find( + (comment) => + comment !== null && + typeof comment === "object" && + "body" in comment && + typeof comment.body === "string" && + (comment.body === marker || comment.body.startsWith(`${marker}\n`)), + ) as { id?: unknown } | undefined; + if (existing) { + if (!Number.isSafeInteger(existing.id) || Number(existing.id) <= 0) { + throw new Error("GitHub returned an invalid pull request comment ID"); + } + existingCommentId = Number(existing.id); + break; + } + if (comments.length < 100) break; + } + + const response = await fetch( + existingCommentId + ? `https://api.github.com/repos/${repoFullName}/issues/comments/${existingCommentId}` + : `https://api.github.com/repos/${repoFullName}/issues/${pullRequestNumber}/comments`, + { + method: existingCommentId ? "PATCH" : "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }, + ); + if (!response.ok) { + const detail = await response.text(); + throw new GitHubApiError( + `Failed to ${existingCommentId ? "update" : "create"} pull request comment (${response.status}): ${detail || response.statusText}`, + response.status, + ); + } + const comment = (await response.json()) as { id?: unknown }; + if (!Number.isSafeInteger(comment.id) || Number(comment.id) <= 0) { + throw new Error("GitHub returned an invalid pull request comment ID"); + } + return Number(comment.id); +} + export async function listOpenGitHubPullRequests( installationId: number, repoFullName: string, diff --git a/web/lib/preview-deployments.ts b/web/lib/preview-deployments.ts index 15581257..c7d26f61 100644 --- a/web/lib/preview-deployments.ts +++ b/web/lib/preview-deployments.ts @@ -16,6 +16,7 @@ import { createGitHubDeployment, findGitHubDeployment, updateGitHubDeploymentStatus, + upsertGitHubPullRequestComment, } from "@/lib/github"; import { resolveRegistryImageHost } from "@/lib/registry-reference"; import { @@ -39,6 +40,62 @@ type PreviewPort = { tlsPassthrough: boolean; }; +type PreviewDeploymentState = + | "pending" + | "in_progress" + | "success" + | "failure" + | "inactive"; + +const previewStateLabels: Record = { + pending: "⏳ Queued", + in_progress: "🚧 Deploying", + success: "✅ Ready", + failure: "❌ Failed", + inactive: "🗑️ Removed", +}; + +function escapeGitHubCommentText(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("@", "@") + .replace(/\s+/g, " ") + .trim(); +} + +async function updatePreviewPullRequestComment(input: { + installationId: number; + repoFullName: string; + baseServiceId: string; + previewGitRef: string; + serviceName: string; + state: PreviewDeploymentState; + description: string; + previewUrl: string | null; +}) { + const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef); + const content = [ + "### Preview deployment", + "", + `**Service:** ${escapeGitHubCommentText(input.serviceName)}`, + `**Status:** ${previewStateLabels[input.state]}`, + input.previewUrl + ? `**Preview:** [Open preview](${input.previewUrl})` + : "**Preview:** No public URL configured", + "", + `${escapeGitHubCommentText(input.description.substring(0, 500))}`, + ].join("\n"); + await upsertGitHubPullRequestComment( + input.installationId, + input.repoFullName, + pullRequestNumber, + ``, + content, + ); +} + function dnsLabelPart(value: string) { return value .trim() @@ -401,7 +458,7 @@ export async function canDeployServiceRevision( export async function updatePreviewGitHubStatus(input: { serviceId: string; serviceRevisionId: string; - state: "pending" | "in_progress" | "success" | "failure" | "inactive"; + state: PreviewDeploymentState; description: string; logUrl?: string; expectedDeploymentId?: number; @@ -411,11 +468,15 @@ export async function updatePreviewGitHubStatus(input: { sql`select pg_advisory_xact_lock(hashtext(${input.serviceId}))`, ); const service = await tx - .select({ previewOfService: services.previewOfService }) + .select({ + name: services.name, + previewOfService: services.previewOfService, + previewGitRef: services.previewGitRef, + }) .from(services) .where(and(eq(services.id, input.serviceId), isNull(services.deletedAt))) .then((rows) => rows[0]); - if (!service?.previewOfService) return null; + if (!service?.previewOfService || !service.previewGitRef) return null; const latest = await tx .select({ id: serviceRevisions.id }) .from(serviceRevisions) @@ -472,6 +533,7 @@ export async function updatePreviewGitHubStatus(input: { .filter((port) => port.domain) .sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0], ); + const previewUrl = primary?.domain ? `https://${primary.domain}` : null; await updateGitHubDeploymentStatus( githubRepo.installationId, githubRepo.repoFullName, @@ -480,11 +542,19 @@ export async function updatePreviewGitHubStatus(input: { { description: input.description.substring(0, 140), logUrl: input.logUrl, - environmentUrl: primary?.domain - ? `https://${primary.domain}` - : undefined, + environmentUrl: previewUrl ?? undefined, }, ); + await updatePreviewPullRequestComment({ + installationId: githubRepo.installationId, + repoFullName: githubRepo.repoFullName, + baseServiceId: service.previewOfService, + previewGitRef: service.previewGitRef, + serviceName: service.name, + state: input.state, + description: input.description, + previewUrl, + }); return true; }); } @@ -590,7 +660,11 @@ export async function inactivatePreviewGitHubDeployments(input: { excludeServiceRevisionId?: string; }) { const service = await db - .select({ previewOfService: services.previewOfService }) + .select({ + name: services.name, + previewOfService: services.previewOfService, + previewGitRef: services.previewGitRef, + }) .from(services) .where(eq(services.id, input.serviceId)) .then((rows) => rows[0]); @@ -610,10 +684,32 @@ export async function inactivatePreviewGitHubDeployments(input: { ne(builds.serviceRevisionId, input.excludeServiceRevisionId), ); } - const deploymentIds = await db - .selectDistinct({ id: builds.githubDeploymentId }) - .from(builds) - .where(and(...conditions)); + const [deploymentIds, primary] = await Promise.all([ + db + .selectDistinct({ id: builds.githubDeploymentId }) + .from(builds) + .where(and(...conditions)), + db + .select({ + id: servicePorts.id, + port: servicePorts.port, + domain: servicePorts.domain, + }) + .from(servicePorts) + .where( + and( + eq(servicePorts.serviceId, input.serviceId), + eq(servicePorts.protocol, "http"), + eq(servicePorts.isPublic, true), + ), + ) + .then( + (ports) => + ports + .filter((port) => port.domain) + .sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0], + ), + ]); await Promise.all( deploymentIds.flatMap(({ id }) => id @@ -629,5 +725,17 @@ export async function inactivatePreviewGitHubDeployments(input: { : [], ), ); + if (!input.excludeServiceRevisionId && service.previewGitRef) { + await updatePreviewPullRequestComment({ + installationId: githubRepo.installationId, + repoFullName: githubRepo.repoFullName, + baseServiceId: service.previewOfService, + previewGitRef: service.previewGitRef, + serviceName: service.name, + state: "inactive", + description: input.description, + previewUrl: primary?.domain ? `https://${primary.domain}` : null, + }); + } return deploymentIds.length; } diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts index 47b793d2..3e4a665f 100644 --- a/web/tests/github.test.ts +++ b/web/tests/github.test.ts @@ -7,6 +7,7 @@ import { isFullCommitSha, resolveGitHubCommit, resolveGitHubPullRequestMergeRef, + upsertGitHubPullRequestComment, } from "@/lib/github"; afterEach(() => { @@ -165,4 +166,87 @@ describe("GitHub pull request deployment helpers", () => { "https://api.github.com/repos/acme/app/commits?sha=refs%2Fpull%2F42%2Fmerge&per_page=1", ]); }); + + it("creates the marked pull request comment when it is missing", async () => { + configureGitHubApp(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ token: "installation-token" })) + .mockResolvedValueOnce(Response.json([])) + .mockResolvedValueOnce(Response.json({ id: 501 }, { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + upsertGitHubPullRequestComment( + 10, + "acme/app", + 42, + "", + "Preview is ready", + ), + ).resolves.toBe(501); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/repos/acme/app/issues/42/comments?per_page=100&page=1", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer installation-token", + }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "https://api.github.com/repos/acme/app/issues/42/comments", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + body: "\nPreview is ready", + }), + }), + ); + }); + + it("replaces the existing marked pull request comment", async () => { + configureGitHubApp(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ token: "installation-token" })) + .mockResolvedValueOnce( + Response.json([ + { + id: 501, + body: "\nPreview queued", + }, + ]), + ) + .mockResolvedValueOnce(Response.json({ id: 501 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + upsertGitHubPullRequestComment( + 10, + "acme/app", + 42, + "", + "Preview is ready", + ), + ).resolves.toBe(501); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "https://api.github.com/repos/acme/app/issues/comments/501", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + body: "\nPreview is ready", + }), + }), + ); + expect( + fetchMock.mock.calls.some( + ([url, init]) => + String(url).endsWith("/issues/42/comments") && + (init as RequestInit | undefined)?.method === "POST", + ), + ).toBe(false); + }); }); diff --git a/web/tests/preview-deployments.test.ts b/web/tests/preview-deployments.test.ts index 187d5379..8e6efd0f 100644 --- a/web/tests/preview-deployments.test.ts +++ b/web/tests/preview-deployments.test.ts @@ -58,6 +58,7 @@ const mocks = vi.hoisted(() => { tx, getSetting: vi.fn(), updateGitHubDeploymentStatus: vi.fn(), + upsertGitHubPullRequestComment: vi.fn(), db: { transaction: vi.fn( async (operation: (transaction: typeof tx) => unknown) => { @@ -80,6 +81,7 @@ vi.mock("@/lib/github", () => ({ createGitHubDeployment: vi.fn(), findGitHubDeployment: vi.fn(), updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus, + upsertGitHubPullRequestComment: mocks.upsertGitHubPullRequestComment, })); import { @@ -196,6 +198,7 @@ describe("preview service cloning", () => { mocks.updatedValues.length = 0; mocks.getSetting.mockResolvedValue("apps.example.com"); mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined); + mocks.upsertGitHubPullRequestComment.mockResolvedValue(501); process.env.REGISTRY_HOST = "registry.example.com"; }); @@ -350,17 +353,30 @@ describe("preview service cloning", () => { }); }); - it("holds the service lock while publishing the current GitHub status", async () => { - mocks.selectResults.push( - [{ previewOfService: baseService.id }], - [{ id: "revision-1" }], - [{ id: 303 }], - [{ installationId: 101, repoFullName: "acme/app" }], - [{ id: "port-1", port: 3000, domain: "preview.apps.example.com" }], - ); + it("holds the service lock while replacing the preview status comment", async () => { + const queueStatusReads = () => + mocks.selectResults.push( + [ + { + name: "Web API (PR #42)", + previewOfService: baseService.id, + previewGitRef: "refs/pull/42/merge", + }, + ], + [{ id: "revision-1" }], + [{ id: 303 }], + [{ installationId: 101, repoFullName: "acme/app" }], + [{ id: "port-1", port: 3000, domain: "preview.apps.example.com" }], + ); + queueStatusReads(); + queueStatusReads(); mocks.updateGitHubDeploymentStatus.mockImplementation(async () => { expect(mocks.transactionState.active).toBe(true); }); + mocks.upsertGitHubPullRequestComment.mockImplementation(async () => { + expect(mocks.transactionState.active).toBe(true); + return 501; + }); await expect( updatePreviewGitHubStatus({ @@ -370,14 +386,32 @@ describe("preview service cloning", () => { description: "Preview is ready", }), ).resolves.toBe(true); - expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith( + await expect( + updatePreviewGitHubStatus({ + serviceId: "preview-service", + serviceRevisionId: "revision-1", + state: "failure", + description: "Preview rollout failed", + }), + ).resolves.toBe(true); + expect(mocks.updateGitHubDeploymentStatus).toHaveBeenLastCalledWith( 101, "acme/app", 303, - "success", + "failure", expect.objectContaining({ environmentUrl: "https://preview.apps.example.com", }), ); + expect(mocks.upsertGitHubPullRequestComment).toHaveBeenCalledTimes(2); + expect(mocks.upsertGitHubPullRequestComment).toHaveBeenLastCalledWith( + 101, + "acme/app", + 42, + ``, + expect.stringMatching( + /Service[\s\S]*Web API \(PR #42\)[\s\S]*Status[\s\S]*Failed[\s\S]*Preview[\s\S]*https:\/\/preview\.apps\.example\.com[\s\S]*Preview rollout failed/, + ), + ); }); });