From 1fdc0d79fd5ec39f7b8227dc4c9fbeb573c988f4 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 26 Aug 2026 09:14:16 +0200 Subject: [PATCH] feat(web): surface pull request deployments with one-click preview links The PR pane buries provider preview links (Vercel and similar) inside the Checks section as generic Details rows. This reads GitHub's Deployments API for the head commit, adds an optional deployments field to the detail contract, renders a Deployments section in the Summary tab, and puts an Open preview button in the pane header: one live environment opens directly, several open a menu. Co-Authored-By: Claude Fable 5 --- .../src/pullRequest/GitHubPullRequestCli.ts | 31 ++++ .../GitHubPullRequestProvider.test.ts | 137 ++++++++++++---- .../pullRequest/GitHubPullRequestProvider.ts | 9 +- .../src/pullRequest/PullRequestProvider.ts | 3 + .../src/pullRequest/PullRequestService.ts | 3 + .../pullRequest/gitHubPullRequestJson.test.ts | 147 ++++++++++++++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 123 +++++++++++++++ .../pullRequest/PullRequestDetailPanel.tsx | 59 +++++++ .../pullRequest/PullRequestSummaryTab.tsx | 30 ++++ .../pullRequest/pullRequestPresentation.tsx | 50 ++++++ docs/user/source-control.md | 6 + packages/contracts/src/pullRequest.test.ts | 64 ++++++++ packages/contracts/src/pullRequest.ts | 38 +++++ 13 files changed, 667 insertions(+), 33 deletions(-) diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 2084a50d0206..5612ec3e8e80 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -7,6 +7,7 @@ import { resolvePullRequestAuthorFilter, type PullRequestAction, type PullRequestActor, + type PullRequestDeployment, type PullRequestInvolvement, type PullRequestListFilters, type PullRequestListState, @@ -32,6 +33,7 @@ import { buildReviewerRequestJson, decodeActorAvatarsJson, decodePullRequestActivityJson, + decodePullRequestDeploymentsJson, decodePullRequestDetailJson, decodePullRequestFilesJson, decodePullRequestListJson, @@ -51,6 +53,7 @@ import { PULL_REQUEST_ACTIVITY_JSON_FIELDS, BASE_COMPARISON_GRAPHQL_QUERY, decodeBaseComparisonJson, + PULL_REQUEST_DEPLOYMENTS_GRAPHQL_QUERY, PULL_REQUEST_DETAIL_JSON_FIELDS, PULL_REQUEST_LIST_JSON_FIELDS, PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, @@ -383,6 +386,18 @@ export class GitHubPullRequestCli extends Context.Service< readonly allowReserve?: boolean | undefined; }) => Effect.Effect; + /** + * Where the change is running, one row per environment. Its own read because `gh pr view` + * reports no deployment of any kind, and one a caller is expected to degrade rather than fail + * on: a preview environment is an extra the detail is readable without. + */ + readonly getPullRequestDeployments: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect, GitHubPullRequestCliError>; + readonly getPullRequestActivity: (input: { readonly cwd: string; readonly repository: string; @@ -1373,6 +1388,22 @@ export const make = Effect.gen(function* () { }); }, + getPullRequestDeployments: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDeployments", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: PULL_REQUEST_DEPLOYMENTS_GRAPHQL_QUERY, + decode: decodePullRequestDeploymentsJson, + }); + }, + getPullRequestActivity: (input) => github .execute({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 2555c05dc8fc..1a33fe062e60 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -1,12 +1,44 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { PullRequestReaction } from "@t3tools/contracts"; +import type { PullRequestDeployment, PullRequestReaction } from "@t3tools/contracts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; +// Shared by every fixture below that needs a full pull request detail and only overrides the +// one or two fields its scenario is actually about. +const openDetail = { + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headRepositoryOwner: "acme", + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], +}; + describe("gitHubViewerPermissions", () => { it("offers everything to a viewer who can write to the repository", () => { expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ @@ -111,6 +143,7 @@ describe("gitHubViewerPermissions", () => { }), getViewerAccess: () => Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }), + getPullRequestDeployments: () => Effect.succeed([]), }), ), ), @@ -118,36 +151,6 @@ describe("gitHubViewerPermissions", () => { }); describe("getViewerPermissions", () => { - const openDetail = { - authorId: null, - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - author: null, - headRepositoryOwner: "acme", - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - reviewDecision: null, - additions: 1, - deletions: 1, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - hasTeamReviewRequest: false, - checksState: null, - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - checks: [], - comments: [], - commits: [], - }; - const layerWithComparison = ( comparison: Effect.Effect<{ readonly behindBy: number | null; @@ -245,6 +248,78 @@ describe("getViewerPermissions", () => { ); }); +describe("getChangeRequest deployments", () => { + // A pull request whose head repository is unknown, so nothing but the deployments is read. + const pullRequest = { ...openDetail, headRepositoryOwner: null }; + + const detailWithDeployments = ( + deployments: Effect.Effect< + ReadonlyArray, + GitHubPullRequestCli.GitHubPullRequestCliError + >, + ) => + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(pullRequest), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + getViewerAccess: () => Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }), + getPullRequestDeployments: () => deployments, + }); + + const preview: PullRequestDeployment = { + environment: "Preview", + status: "success", + url: "https://preview.example.com", + }; + + it.effect("carries the host's environments on the detail", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.deployments).toEqual([preview]); + }).pipe(Effect.provide(detailWithDeployments(Effect.succeed([preview])))), + ); + + it.effect("leaves the field absent where the deployments could not be read", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // Absent rather than empty: the rest of the page must survive a read that failed, and + // "none" is a claim this answer cannot make. + expect(detail.deployments).toBeUndefined(); + expect(detail.title).toBe("Pull request 7"); + }).pipe( + Effect.provide( + detailWithDeployments( + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "getPullRequestDeployments", + cause: new Error("unreadable"), + }), + ), + ), + ), + ), + ); +}); + describe("getChangeRequest commits", () => { const baseDetail = { authorId: null, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c2ed..4f09f7f03fb0 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -252,12 +252,16 @@ export const make = Effect.gen(function* () { // A small permissions query replaces the deeply paginated review-thread walk on the // core path. Writes ask again immediately before mutating, so this is presentation. cli.getViewerAccess(input), + // Undefined rather than an empty list where the read failed — a rate limit, a token + // that may not read deployments — so the page says nothing instead of claiming there + // are none. + cli.getPullRequestDeployments(input).pipe(Effect.orElseSucceed(() => undefined)), ], - { concurrency: 3 }, + { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequest")), Effect.map( - ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ([detail, repository, viewerAccess, deployments]): ProviderChangeRequestDetail => ({ ...detail.pullRequest, reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ login, @@ -278,6 +282,7 @@ export const make = Effect.gen(function* () { ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), + ...(deployments === undefined ? {} : { deployments }), }), ), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 644f3552cbc5..01f52b998e0f 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -9,6 +9,7 @@ import type { PullRequestCheck, PullRequestComment, PullRequestCommit, + PullRequestDeployment, PullRequestInvolvement, PullRequestLabel, PullRequestListFilters, @@ -158,6 +159,8 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly closedAt: string | null; readonly reviewers: ReadonlyArray; readonly checks: ReadonlyArray; + /** Where the change is running, newest first. Absent from a read that could not ask. */ + readonly deployments?: ReadonlyArray; readonly mergeCapabilities: PullRequestMergeCapabilities; readonly viewerPermissions: PullRequestViewerPermissions; /** Absent from a host that cannot compare the branch with its base, which is most of them. */ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 3a0d1aac699d..a63d3bda0298 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1172,6 +1172,9 @@ export const make = Effect.gen(function* () { reviewers: changeRequest.reviewers, labels: changeRequest.labels, checks: changeRequest.checks, + ...(changeRequest.deployments === undefined + ? {} + : { deployments: changeRequest.deployments }), mergeCapabilities: changeRequest.mergeCapabilities, viewerPermissions: changeRequest.viewerPermissions, ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac3000a0..0574d87c3539 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -6,6 +6,7 @@ import { buildReviewerRequestJson, decodeBaseComparisonJson, decodePullRequestActivityJson, + decodePullRequestDeploymentsJson, decodePullRequestDetailJson, decodePullRequestFilesJson, decodePullRequestListJson, @@ -1361,3 +1362,149 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +describe("where the change is running", () => { + const deployments = (nodes: ReadonlyArray) => + JSON.stringify({ + data: { + repository: { + pullRequest: { commits: { nodes: [{ commit: { deployments: { nodes } } }] } }, + }, + }, + }); + + const deployment = (entry: Record) => ({ + environment: "Preview", + latestStatus: { + state: "SUCCESS", + environmentUrl: "https://preview.example.com", + logUrl: "https://logs.example.com", + }, + ...entry, + }); + + it("reads an environment, where it stands, and where to open it", () => { + expect(expectSuccess(decodePullRequestDeploymentsJson(deployments([deployment({})])))).toEqual([ + { environment: "Preview", status: "success", url: "https://preview.example.com" }, + ]); + }); + + it("maps each of GitHub's states onto what a reader is waiting for", () => { + const statuses = [ + "PENDING", + "QUEUED", + "WAITING", + "IN_PROGRESS", + "SUCCESS", + "FAILURE", + "ERROR", + "INACTIVE", + "DESTROYED", + "SOMETHING_NEW", + ].map( + (state, index) => + expectSuccess( + decodePullRequestDeploymentsJson( + deployments([deployment({ environment: `env-${index}`, latestStatus: { state } })]), + ), + )[0]?.status, + ); + + expect(statuses).toEqual([ + "pending", + "pending", + "pending", + "in-progress", + "success", + "failure", + "failure", + "inactive", + "inactive", + // A state this build has never heard of is one nothing can be claimed about yet. + "pending", + ]); + }); + + it("treats a deployment the host has said nothing about as pending", () => { + expect( + expectSuccess( + decodePullRequestDeploymentsJson(deployments([deployment({ latestStatus: null })])), + ), + ).toEqual([{ environment: "Preview", status: "pending", url: null }]); + }); + + it("falls back to the build log where the environment has no address of its own", () => { + const [entry] = expectSuccess( + decodePullRequestDeploymentsJson( + deployments([ + deployment({ + latestStatus: { + state: "FAILURE", + environmentUrl: "", + logUrl: "https://logs.example.com", + }, + }), + ]), + ), + ); + + expect(entry?.url).toBe("https://logs.example.com"); + }); + + it("never opens a build log for a live deployment, even where it has no address of its own", () => { + const [entry] = expectSuccess( + decodePullRequestDeploymentsJson( + deployments([ + deployment({ + latestStatus: { + state: "SUCCESS", + environmentUrl: "", + logUrl: "https://logs.example.com", + }, + }), + ]), + ), + ); + + expect(entry?.url).toBeNull(); + }); + + it("keeps the newest deployment of each environment, in the order the host answered", () => { + const decoded = expectSuccess( + decodePullRequestDeploymentsJson( + deployments([ + // Newest first, which is what the query orders by, so the retry leads the failure it + // replaced and the failure is never shown. + deployment({ environment: "Preview", latestStatus: { state: "SUCCESS" } }), + deployment({ environment: "Preview", latestStatus: { state: "FAILURE" } }), + deployment({ environment: "Storybook", latestStatus: { state: "SUCCESS" } }), + ]), + ), + ); + + expect(decoded.map((entry) => [entry.environment, entry.status])).toEqual([ + ["Preview", "success"], + ["Storybook", "success"], + ]); + }); + + it("skips a deployment with no environment to name it by", () => { + expect( + expectSuccess( + decodePullRequestDeploymentsJson(deployments([deployment({ environment: " " }), null])), + ), + ).toEqual([]); + }); + + it("answers nothing for a pull request the viewer cannot see", () => { + expect( + expectSuccess( + decodePullRequestDeploymentsJson(JSON.stringify({ data: { repository: null } })), + ), + ).toEqual([]); + }); + + it("refuses a body that is not the answer to this question", () => { + expect(Result.isSuccess(decodePullRequestDeploymentsJson("{"))).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea111b3..d7c0933582bb 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -9,6 +9,8 @@ import type { PullRequestChecksState, PullRequestComment, PullRequestCommit, + PullRequestDeployment, + PullRequestDeploymentStatus, PullRequestLabel, PullRequestMergeCapabilities, PullRequestOmittedFileStat, @@ -1987,6 +1989,127 @@ export function decodeBaseComparisonJson( }); } +/** + * Where the change is running, read off its head commit. + * + * GitHub hangs a deployment on the commit it was built from rather than on the pull request, so + * the last commit is the only one whose environments describe the change as it stands now — an + * earlier commit's previews were torn down or replaced by this one's. Newest first, which is what + * makes the first deployment of an environment the one serving it now. + */ +export const PULL_REQUEST_DEPLOYMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + commits(last: 1) { + nodes { + commit { + deployments(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + environment + latestStatus { state environmentUrl logUrl } + } + } + } + } + } + } + } +}`; + +const RawDeploymentSchema = Schema.Struct({ + environment: Schema.optional(Schema.NullOr(Schema.String)), + /** Null until the deployment's first status lands, which is a deployment nobody has built yet. */ + latestStatus: Schema.optional( + Schema.NullOr( + Schema.Struct({ + state: Schema.optional(Schema.NullOr(Schema.String)), + environmentUrl: Schema.optional(Schema.NullOr(Schema.String)), + logUrl: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawDeploymentsSchema = Schema.Struct({ + data: Schema.Struct({ + /** Null for a repository, or a number, the viewer cannot see — which is no deployments. */ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + commits: Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.Struct({ + deployments: Schema.Struct({ + nodes: Schema.Array(Schema.NullOr(RawDeploymentSchema)), + }), + }), + }), + ), + ), + }), + }), + ), + }), + ), + }), +}); + +const decodeDeployments = decodeJsonResult(RawDeploymentsSchema); + +/** + * Anything GitHub adds, and a deployment with no status at all, reads as pending: a deployment + * nobody has heard from is one that has not happened yet. + */ +function toDeploymentStatus(state: string | null | undefined): PullRequestDeploymentStatus { + switch (state?.trim().toUpperCase()) { + case "IN_PROGRESS": + return "in-progress"; + case "ACTIVE": + case "SUCCESS": + return "success"; + case "ERROR": + case "FAILURE": + return "failure"; + case "ABANDONED": + case "DESTROYED": + case "INACTIVE": + return "inactive"; + default: + return "pending"; + } +} + +/** + * One row per environment rather than one per deployment of it. A commit that was redeployed — a + * retried build, a second provider pushing to the same name — carries the same environment several + * times over, and the query hands them over newest first, so the first one to name an environment + * is the one serving it now. + */ +export function decodePullRequestDeploymentsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeDeployments(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const commits = decoded.success.data.repository?.pullRequest?.commits.nodes ?? []; + const latestByEnvironment = new Map(); + for (const node of commits.flatMap((commit) => commit?.commit.deployments.nodes ?? [])) { + const environment = trimmed(node?.environment); + if (environment === null || latestByEnvironment.has(environment)) continue; + const status = toDeploymentStatus(node?.latestStatus?.state); + const environmentUrl = trimmed(node?.latestStatus?.environmentUrl); + // The log fallback is for a deployment worth investigating. A success with no environment + // URL has nothing live to open, so it stays null rather than pointing at a build log — the + // header button promises a preview, not a log. + const url = + environmentUrl ?? (status === "success" ? null : trimmed(node?.latestStatus?.logUrl)); + latestByEnvironment.set(environment, { environment, status, url }); + } + return Result.succeed([...latestByEnvironment.values()]); +} + export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 731a3acecef4..99cda3209ed7 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -24,6 +24,7 @@ import { GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, + GlobeIcon, HammerIcon, LayersIcon, MessageCircleQuestionIcon, @@ -1104,6 +1105,15 @@ export function PullRequestDetailPanel({ (entry) => entry.outcome === "approved" && !entry.stale, ).length : 0; + // Environments that are both live and reachable, since deployments arrive newest first. A + // live deployment can still have nothing to open, which is a status rather than a link. + const openableDeployments = + detail?.deployments?.filter( + (deployment): deployment is typeof deployment & { url: string } => + deployment.status === "success" && !!deployment.url, + ) ?? []; + const soleOpenableDeployment = + openableDeployments.length === 1 ? openableDeployments[0] : undefined; if (detailQuery.isPending && !detail) { return ; @@ -1308,6 +1318,55 @@ export function PullRequestDetailPanel({ {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} ) : null} + {soleOpenableDeployment ? ( + + + void readLocalApi()?.shell.openExternal(soleOpenableDeployment.url) + } + /> + } + > + + + Open preview + + ) : openableDeployments.length > 1 ? ( + // Two or more live environments means one button can no longer say which URL it + // opens, so it becomes a menu instead. No tooltip here: a tooltip on a menu + // trigger fights the popup for the same space. + + + } + > + + + + {openableDeployments.map((deployment) => ( + void readLocalApi()?.shell.openExternal(deployment.url)} + > + + {deployment.environment} + + ))} + + + ) : null} + {/* No section at all rather than an empty one: the field is absent from a host that has no + deployments to report and from a read that failed, neither of which is a section a + reader can ever fill. */} + {detail.deployments && detail.deployments.length > 0 ? ( +
+
+ {detail.deployments.map((deployment) => ( + + ))} +
+
+ ) : null} +
; + +export function pullRequestDeploymentStatusLabel(status: PullRequestDeploymentStatus): string { + return DEPLOYMENT_STATUS_PRESENTATION[status].label; +} + +export function PullRequestDeploymentStatusIcon({ + status, +}: { + status: PullRequestDeploymentStatus; +}) { + const presentation = DEPLOYMENT_STATUS_PRESENTATION[status]; + return ( + + ); +} + /** * The rollup a listing row carries, which is one word rather than the checks behind it. The * headline is GitHub's own wording, so a reader who knows that page reads this one the same way. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..e52ede3fc3a3 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -45,6 +45,12 @@ T3 Code works with the platforms your team already uses: - Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead of in T3 Code - Check out a teammate's branch to review code locally +**See preview deployments** + +- If a pull request has preview deployments (for example, Vercel preview environments on GitHub), the review's Summary tab shows a Deployments section listing each environment and its current state +- A globe button in the review header opens the newest live preview in your browser +- When there are several environments, the globe button opens a menu so you can pick which one to open + **Fix what you wrote, in place** - Rewrite a pull request's title and description from the review itself, in Markdown, with a diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 4e54ca308a78..e69816e34339 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestActionInput, PullRequestCapabilities, + PullRequestDetail, PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, @@ -213,6 +214,69 @@ describe("PullRequestCapabilities", () => { }); }); +describe("PullRequestDetail", () => { + const decodeDetail = Schema.decodeUnknownSync(PullRequestDetail); + const base = { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + projectId: "project-1", + projectTitle: "t3code", + workspaceRoot: "/w", + repository: "acme/web", + number: 7, + title: "Add a pull requests page", + body: "", + url: "https://github.com/acme/web/pull/7", + author: null, + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feat/page", + baseBranch: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; + + it("decodes a server that says nothing about deployments as a server that was not asked", () => { + expect(decodeDetail(base).deployments).toBeUndefined(); + }); + + it("carries the environments a server does report", () => { + expect( + decodeDetail({ + ...base, + deployments: [ + { environment: "Preview", status: "success", url: "https://preview.example.com" }, + ], + }).deployments, + ).toEqual([{ environment: "Preview", status: "success", url: "https://preview.example.com" }]); + }); +}); + describe("naming the reader as the author to narrow by", () => { it("reads me as whoever is signed in, however it is written", () => { expect(resolvePullRequestAuthorFilter("me", "octocat")).toBe("octocat"); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index a49868937844..42659a56674b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -147,6 +147,38 @@ export const PullRequestCheck = Schema.Struct({ }); export type PullRequestCheck = typeof PullRequestCheck.Type; +/** + * Where a deployment of the change stands. "pending" covers everything before the build starts — + * queued, waiting on an approval, and a deployment the host has said nothing about yet. + * "inactive" is one that was superseded or torn down, so its environment no longer serves this + * change. + */ +export const PullRequestDeploymentStatus = Schema.Literals([ + "pending", + "in-progress", + "success", + "failure", + "inactive", +]); +export type PullRequestDeploymentStatus = typeof PullRequestDeploymentStatus.Type; + +/** + * One environment the change has been deployed to — a preview build, a staging box — holding that + * environment's latest deployment: a push redeploys the same environment, and every build before + * the current one is history nobody is going to open. + */ +export const PullRequestDeployment = Schema.Struct({ + environment: TrimmedNonEmptyString, + status: PullRequestDeploymentStatus, + /** + * Where the deployment can be opened, which is the whole point of showing it. The environment's + * own address where the host reported one, the build log where it did not, and null where the + * deployment has nothing to open yet — a queued build has no address. + */ + url: Schema.NullOr(Schema.String), +}); +export type PullRequestDeployment = typeof PullRequestDeployment.Type; + /** * The reactions a remark can carry. GitHub's eight, which is also what the picker offers: GitLab * accepts any emoji as an award, and the ones outside this set are read as nothing rather than @@ -668,6 +700,12 @@ export const PullRequestDetail = Schema.Struct({ reviewers: Schema.Array(PullRequestActor), labels: Schema.Array(PullRequestLabel), checks: Schema.Array(PullRequestCheck), + /** + * Where the change is running, most recent first. Absent from a host that does not report + * deployments, which is most of them, and from a read that could not ask — neither of which is + * the same as the empty list a change that deploys nowhere answers with. + */ + deployments: Schema.optional(Schema.Array(PullRequestDeployment)), mergeCapabilities: PullRequestMergeCapabilities, /** * Who the host says the reader is, which is the one thing a conversation cannot be read without