diff --git a/github/server/lib/deployment.test.ts b/github/server/lib/deployment.test.ts new file mode 100644 index 00000000..d1dd3371 --- /dev/null +++ b/github/server/lib/deployment.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { DeploymentError, getPreviewDeployment } from "./deployment.ts"; + +const realFetch = globalThis.fetch; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +function setFetch(impl: (input: unknown, init?: unknown) => Promise) { + globalThis.fetch = impl as unknown as typeof globalThis.fetch; +} +const urlOf = (i: unknown) => + typeof i === "string" ? i : (i as { url: string }).url; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +async function expectRejectCode( + fn: () => Promise, + code: DeploymentError["code"], +): Promise { + let caught: unknown; + try { + await fn(); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(DeploymentError); + expect((caught as DeploymentError).code).toBe(code); + return caught as DeploymentError; +} + +const SHA = "f9f522ce9642cf7f2024e45b9ddc618a6f78bf8c"; + +describe("getPreviewDeployment", () => { + test("returns the environment_url from the newest successful status", async () => { + const calls: string[] = []; + setFetch(async (input, init) => { + const url = urlOf(input); + calls.push(url); + const headers = (init as { headers: Record }).headers; + expect(headers.Authorization).toBe("Bearer ghs_tok"); + if (url.includes("/deployments?")) { + expect(url).toBe( + `https://api.github.com/repos/acme/store/deployments?sha=${SHA}&per_page=10`, + ); + return json([{ id: 42, environment: "staging" }]); + } + expect(url).toBe( + "https://api.github.com/repos/acme/store/deployments/42/statuses?per_page=30", + ); + return json([ + { + state: "success", + environment_url: "https://x--store.preview.vtex.app", + }, + { state: "in_progress", environment_url: null }, + ]); + }); + + const r = await getPreviewDeployment({ + token: "ghs_tok", + owner: "acme", + repo: "store", + sha: SHA, + }); + + expect(r).toEqual({ + environmentUrl: "https://x--store.preview.vtex.app", + environment: "staging", + state: "success", + deploymentId: 42, + }); + expect(calls.length).toBe(2); + }); + + test("skips deployments with no successful url and scans the next one", async () => { + setFetch(async (input) => { + const url = urlOf(input); + if (url.includes("/deployments?")) { + return json([ + { id: 1, environment: "production" }, + { id: 2, environment: "staging" }, + ]); + } + if (url.includes("/deployments/1/statuses")) { + return json([{ state: "failure", environment_url: null }]); + } + return json([ + { state: "success", environment_url: "https://prev.preview.vtex.app" }, + ]); + }); + + const r = await getPreviewDeployment({ + token: "t", + owner: "a", + repo: "b", + sha: SHA, + }); + expect(r.environmentUrl).toBe("https://prev.preview.vtex.app"); + expect(r.deploymentId).toBe(2); + expect(r.environment).toBe("staging"); + }); + + test("one deployment's status read failing drops only that deployment, not the scan", async () => { + setFetch(async (input) => { + const url = urlOf(input); + if (url.includes("/deployments?")) { + return json([ + { id: 1, environment: "production" }, + { id: 2, environment: "staging" }, + ]); + } + // Deployment 1's statuses 500 — must not abort; deployment 2 still wins. + if (url.includes("/deployments/1/statuses")) { + return json({ message: "boom" }, 500); + } + return json([ + { state: "success", environment_url: "https://prev.preview.vtex.app" }, + ]); + }); + + const r = await getPreviewDeployment({ + token: "t", + owner: "a", + repo: "b", + sha: SHA, + }); + expect(r.environmentUrl).toBe("https://prev.preview.vtex.app"); + expect(r.deploymentId).toBe(2); + }); + + test("no deployments for the sha → empty (not an error)", async () => { + setFetch(async () => json([])); + const r = await getPreviewDeployment({ + token: "t", + owner: "a", + repo: "b", + sha: SHA, + }); + expect(r).toEqual({ + environmentUrl: null, + environment: null, + state: null, + deploymentId: null, + }); + }); + + test("deployment exists but no success status yet → empty (in-flight)", async () => { + setFetch(async (input) => { + const url = urlOf(input); + if (url.includes("/deployments?")) { + return json([{ id: 7, environment: "staging" }]); + } + return json([{ state: "in_progress", environment_url: null }]); + }); + const r = await getPreviewDeployment({ + token: "t", + owner: "a", + repo: "b", + sha: SHA, + }); + expect(r.environmentUrl).toBeNull(); + }); + + test("passes the environment filter through to the deployments query", async () => { + setFetch(async (input) => { + const url = urlOf(input); + if (url.includes("/deployments?")) { + expect(url).toContain("&environment=staging"); + return json([]); + } + return json([]); + }); + await getPreviewDeployment({ + token: "t", + owner: "a", + repo: "b", + sha: SHA, + environment: "staging", + }); + }); + + test("percent-encodes owner/repo path segments", async () => { + setFetch(async (input) => { + const url = urlOf(input); + expect(url).toContain("/repos/deco-cx/my.repo/deployments"); + return json([]); + }); + await getPreviewDeployment({ + token: "t", + owner: "deco-cx", + repo: "my.repo", + sha: SHA, + }); + }); + + test("rejects owner/repo/sha with path-injection characters before fetching", async () => { + setFetch(async () => { + throw new Error("should not fetch"); + }); + for (const bad of ["..", "a/b", "x?y", "../../user"]) { + await expectRejectCode( + () => + getPreviewDeployment({ token: "t", owner: bad, repo: "b", sha: SHA }), + "invalid_input", + ); + await expectRejectCode( + () => + getPreviewDeployment({ token: "t", owner: "a", repo: bad, sha: SHA }), + "invalid_input", + ); + } + for (const badSha of ["", "nothex", "g".repeat(40), "abc/../def"]) { + await expectRejectCode( + () => + getPreviewDeployment({ + token: "t", + owner: "a", + repo: "b", + sha: badSha, + }), + "invalid_input", + ); + } + }); + + test("missing token → unauthorized without a network call", async () => { + setFetch(async () => { + throw new Error("should not fetch"); + }); + await expectRejectCode( + () => + getPreviewDeployment({ token: "", owner: "a", repo: "b", sha: SHA }), + "unauthorized", + ); + }); + + test("403 → unauthorized (token may lack deployments:read)", async () => { + setFetch(async () => json({ message: "Resource not accessible" }, 403)); + await expectRejectCode( + () => + getPreviewDeployment({ token: "t", owner: "a", repo: "b", sha: SHA }), + "unauthorized", + ); + }); + + test("404 → not_found", async () => { + setFetch(async () => json({ message: "Not Found" }, 404)); + await expectRejectCode( + () => + getPreviewDeployment({ token: "t", owner: "a", repo: "b", sha: SHA }), + "not_found", + ); + }); + + test("5xx → upstream_error", async () => { + setFetch(async () => json({ message: "boom" }, 503)); + await expectRejectCode( + () => + getPreviewDeployment({ token: "t", owner: "a", repo: "b", sha: SHA }), + "upstream_error", + ); + }); + + test("a 200 with an unreadable body → upstream_error", async () => { + setFetch( + async () => + new Response("not json", { + status: 200, + headers: { "Content-Type": "text/html" }, + }), + ); + await expectRejectCode( + () => + getPreviewDeployment({ token: "t", owner: "a", repo: "b", sha: SHA }), + "upstream_error", + ); + }); +}); diff --git a/github/server/lib/deployment.ts b/github/server/lib/deployment.ts new file mode 100644 index 00000000..ce4152d7 --- /dev/null +++ b/github/server/lib/deployment.ts @@ -0,0 +1,256 @@ +/** + * Preview deployment lookup. + * + * Some hosts (notably VTEX FastStore WebOps) publish a PR's preview URL neither + * as a commit-status `target_url` nor as a bot comment — the two sources the PR + * panel already reads. They record it as a GitHub **Deployment** whose latest + * `deployment_status.environment_url` points at the per-branch preview (e.g. + * `https://--.preview.vtex.app`). + * + * The upstream github-mcp `pull_request_read` has no deployments method, so this + * first-party tool fills the gap by reading the REST Deployments API directly + * with the caller's own token — same shape as `getCheckRun`. + * + * Reads use the caller's token (a repo-scoped installation token with + * `deployments:read`/`repo`, or a user-to-server token) — no GitHub App private + * key needed. + */ + +const GITHUB_API = "https://api.github.com"; + +/** How many deployments (newest-first) to inspect for a sha before giving up. + * A single commit usually has 1–2 (staging + production); the cap bounds the + * status fan-out on pathological histories. */ +const MAX_DEPLOYMENTS_SCANNED = 10; + +/** How many statuses (newest-first) to read per deployment. */ +const STATUSES_PER_PAGE = 30; + +export interface DeploymentPreview { + /** `environment_url` of the newest matching successful deployment status, or + * null when no deployment for the sha has published one. */ + environmentUrl: string | null; + /** The deployment's environment (e.g. "staging", "production"), or null. */ + environment: string | null; + /** The winning status's state (always "success" when environmentUrl is set). */ + state: string | null; + /** The deployment id the url came from, or null. */ + deploymentId: number | null; +} + +export type DeploymentErrorCode = + | "invalid_input" + | "unauthorized" + | "not_found" + | "upstream_error"; + +/** Error surfaced by GET_PREVIEW_DEPLOYMENT. `code` is stable; `message` is safe + * to show (it never contains the caller token). */ +export class DeploymentError extends Error { + constructor( + public readonly code: DeploymentErrorCode, + message: string, + ) { + super(message); + this.name = "DeploymentError"; + } +} + +function githubHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "deco-cms-github-mcp", + }; +} + +/** + * GitHub owner/repo path segments: alphanumerics plus `.`, `_`, `-`. Rejecting + * anything else (and the bare `.`/`..`) closes path/endpoint injection — an + * unvalidated `/` or `..` would let the caller re-target a different + * api.github.com endpoint once the URL parser normalizes the path. Segments are + * also `encodeURIComponent`-d at the call site as defense in depth. + */ +const SEGMENT_RE = /^[A-Za-z0-9._-]+$/; +/** A git sha: 7–40 hex chars. Also the `sha` query value, so it's validated + * for the same injection reason as the path segments. */ +const SHA_RE = /^[0-9a-fA-F]{7,40}$/; + +function assertValidSegment(kind: "owner" | "repo", value: string): void { + if (!SEGMENT_RE.test(value) || value === "." || value === "..") { + throw new DeploymentError( + "invalid_input", + `"${kind}" contains invalid characters.`, + ); + } +} + +async function githubGet( + url: string, + token: string, + what: string, +): Promise { + let res: Response; + try { + res = await fetch(url, { headers: githubHeaders(token) }); + } catch { + throw new DeploymentError( + "upstream_error", + "GitHub is temporarily unavailable. Please retry.", + ); + } + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new DeploymentError( + "unauthorized", + `Not authorized to read ${what} (${res.status}). ` + + "The token may lack deployments:read.", + ); + } + if (res.status === 404) { + throw new DeploymentError("not_found", `${what} was not found.`); + } + throw new DeploymentError( + "upstream_error", + `GitHub returned ${res.status} while reading ${what}.`, + ); + } + return res; +} + +async function readJson(res: Response): Promise { + try { + return (await res.json()) as T; + } catch { + throw new DeploymentError( + "upstream_error", + "GitHub returned an unreadable response.", + ); + } +} + +interface DeploymentRow { + id?: number; + environment?: string | null; +} +interface DeploymentStatusRow { + state?: string | null; + environment_url?: string | null; +} + +/** + * Find the preview URL for a commit by scanning its GitHub Deployments, + * newest-first, and returning the first `success` status that carries an + * `environment_url`. Returns `environmentUrl: null` (not an error) when the sha + * has deployments but none has published a url yet — an in-flight deploy is a + * normal, non-exceptional state. Throws {@link DeploymentError} on missing auth, + * bad input, or a non-OK GitHub response. + */ +export async function getPreviewDeployment(params: { + token: string; + owner: string; + repo: string; + sha: string; + /** Optional environment filter (e.g. "staging"). Omitted → any environment. */ + environment?: string; +}): Promise { + const { token, owner, repo, sha, environment } = params; + + if (!token) { + throw new DeploymentError( + "unauthorized", + "Missing caller GitHub authorization.", + ); + } + if (!owner || !repo || !sha) { + throw new DeploymentError( + "invalid_input", + `"owner", "repo" and "sha" are required.`, + ); + } + assertValidSegment("owner", owner); + assertValidSegment("repo", repo); + if (!SHA_RE.test(sha)) { + throw new DeploymentError( + "invalid_input", + `"sha" must be a 7–40 character hex git sha.`, + ); + } + if (environment !== undefined && !SEGMENT_RE.test(environment)) { + throw new DeploymentError( + "invalid_input", + `"environment" contains invalid characters.`, + ); + } + + const base = `${GITHUB_API}/repos/${encodeURIComponent( + owner, + )}/${encodeURIComponent(repo)}`; + + const deploymentsUrl = + `${base}/deployments?sha=${sha}&per_page=${MAX_DEPLOYMENTS_SCANNED}` + + (environment ? `&environment=${encodeURIComponent(environment)}` : ""); + const deployments = await readJson( + await githubGet(deploymentsUrl, token, `deployments for ${owner}/${repo}`), + ); + + const empty: DeploymentPreview = { + environmentUrl: null, + environment: null, + state: null, + deploymentId: null, + }; + if (!Array.isArray(deployments) || deployments.length === 0) { + return empty; + } + + // Read every candidate deployment's statuses CONCURRENTLY — the slice bounds + // the fan-out — so the in-flight case (deployments exist, none successful yet: + // exactly what this feature targets) costs one round-trip, not up to + // MAX_DEPLOYMENTS_SCANNED serial hops. A single deployment's status read + // failing drops only that deployment (→ []), never the whole scan. + const candidates = deployments + .slice(0, MAX_DEPLOYMENTS_SCANNED) + .filter( + (d): d is DeploymentRow & { id: number } => typeof d?.id === "number", + ); + const scanned = await Promise.all( + candidates.map(async (dep) => { + try { + const statuses = await readJson( + await githubGet( + `${base}/deployments/${dep.id}/statuses?per_page=${STATUSES_PER_PAGE}`, + token, + `deployment ${dep.id} statuses`, + ), + ); + return { dep, statuses: Array.isArray(statuses) ? statuses : [] }; + } catch { + return { dep, statuses: [] as DeploymentStatusRow[] }; + } + }), + ); + + // Deployments come newest-first, so the first candidate with a successful + // status wins. Statuses are likewise newest-first, so its first `success` + // carrying an environment_url is the current one. + for (const { dep, statuses } of scanned) { + const hit = statuses.find( + (s) => + s?.state === "success" && + typeof s.environment_url === "string" && + s.environment_url.length > 0, + ); + if (hit) { + return { + environmentUrl: hit.environment_url ?? null, + environment: dep.environment ?? null, + state: hit.state ?? null, + deploymentId: dep.id, + }; + } + } + + return empty; +} diff --git a/github/server/tools/get-preview-deployment.ts b/github/server/tools/get-preview-deployment.ts new file mode 100644 index 00000000..c5db060b --- /dev/null +++ b/github/server/tools/get-preview-deployment.ts @@ -0,0 +1,78 @@ +/** + * GET_PREVIEW_DEPLOYMENT — resolve a commit's preview URL from its GitHub + * Deployments. + * + * Some hosts (VTEX FastStore WebOps in particular) publish a PR's preview URL + * only as a GitHub Deployment's `deployment_status.environment_url` — not as a + * commit-status `target_url` and not as a bot comment. The upstream + * `pull_request_read` has no deployments method, so the PR panel can't surface + * those previews. This first-party tool fills the gap by reading the REST + * Deployments API with the caller's own token. + * + * `createPrivateTool` ensures the caller is authenticated; the read is scoped by + * the caller's token (needs deployments:read). + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { getPreviewDeployment } from "../lib/deployment.ts"; +import type { Env } from "../types/env.ts"; + +export function createGetPreviewDeploymentTool() { + return createPrivateTool({ + id: "GET_PREVIEW_DEPLOYMENT", + description: + "Resolve a commit's preview URL from its GitHub Deployments — the newest " + + "successful deployment status's environment_url. Fills a gap in " + + "pull_request_read (no deployments method) for hosts like VTEX FastStore " + + "that publish the preview as a deployment rather than a status target_url " + + "or a bot comment. Returns environmentUrl: null when the commit has no " + + "deployment with a published url yet (e.g. an in-flight deploy). Requires " + + "deployments:read on the caller's token. SECURITY: environmentUrl is set " + + "by whoever wrote the deployment status and is NOT host-validated here — " + + "treat it as untrusted and check it against a preview-host allow-list " + + "before showing it as a trusted link or navigating to it.", + inputSchema: z.object({ + owner: z + .string() + .describe('Repository owner/login, e.g. "acme" (NOT "owner/repo").'), + repo: z + .string() + .describe('The repository NAME only, e.g. "web" (NOT "acme/web").'), + sha: z + .string() + .describe( + "The commit sha to resolve (7–40 hex chars), typically the PR head sha.", + ), + environment: z + .string() + .optional() + .describe( + 'Optional environment filter, e.g. "staging". Omit to match any.', + ), + }), + outputSchema: z.object({ + environmentUrl: z + .string() + .nullable() + .describe( + "Untrusted: the deployment status's environment_url as written by " + + "the deployer. Host-validate before showing or navigating.", + ), + environment: z.string().nullable(), + state: z.string().nullable(), + deploymentId: z.number().nullable(), + }), + execute: async ({ context, runtimeContext }) => { + const env = runtimeContext.env as unknown as Env; + const token = env.MESH_REQUEST_CONTEXT?.authorization ?? ""; + return await getPreviewDeployment({ + token, + owner: context.owner, + repo: context.repo, + sha: context.sha, + environment: context.environment, + }); + }, + }); +} diff --git a/github/server/tools/index.ts b/github/server/tools/index.ts index dae53450..f0b47274 100644 --- a/github/server/tools/index.ts +++ b/github/server/tools/index.ts @@ -9,6 +9,7 @@ import { buildUpstreamTools, getUpstreamToolDefs } from "../lib/mcp-proxy.ts"; import { triggers } from "../lib/trigger-store.ts"; import { createGetCheckRunTool } from "./get-check-run.ts"; +import { createGetPreviewDeploymentTool } from "./get-preview-deployment.ts"; import { createMintRepoTokenTool } from "./mint-repo-token.ts"; /** @@ -21,6 +22,8 @@ import { createMintRepoTokenTool } from "./mint-repo-token.ts"; * - MINT_REPO_TOKEN: mint a repo-scoped, least-privilege installation token. * - GET_CHECK_RUN: read a check run's full output (the upstream * get_check_runs omits it). + * - GET_PREVIEW_DEPLOYMENT: resolve a commit's preview url from its GitHub + * Deployments (upstream pull_request_read has no deployments method). */ export async function getTools() { const toolDefs = await getUpstreamToolDefs(); @@ -29,5 +32,6 @@ export async function getTools() { ...triggers.tools(), createMintRepoTokenTool(), createGetCheckRunTool(), + createGetPreviewDeploymentTool(), ]; }