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