Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolvePullRequestAuthorFilter,
type PullRequestAction,
type PullRequestActor,
type PullRequestDeployment,
type PullRequestInvolvement,
type PullRequestListFilters,
type PullRequestListState,
Expand All @@ -32,6 +33,7 @@ import {
buildReviewerRequestJson,
decodeActorAvatarsJson,
decodePullRequestActivityJson,
decodePullRequestDeploymentsJson,
decodePullRequestDetailJson,
decodePullRequestFilesJson,
decodePullRequestListJson,
Expand All @@ -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,
Expand Down Expand Up @@ -383,6 +386,18 @@ export class GitHubPullRequestCli extends Context.Service<
readonly allowReserve?: boolean | undefined;
}) => Effect.Effect<GitHubBaseComparison, GitHubPullRequestCliError>;

/**
* 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<ReadonlyArray<PullRequestDeployment>, GitHubPullRequestCliError>;

readonly getPullRequestActivity: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down Expand Up @@ -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({
Expand Down
137 changes: 106 additions & 31 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -111,43 +143,14 @@ describe("gitHubViewerPermissions", () => {
}),
getViewerAccess: () =>
Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }),
getPullRequestDeployments: () => Effect.succeed([]),
}),
),
),
);
});

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;
Expand Down Expand Up @@ -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<PullRequestDeployment>,
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,
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -278,6 +282,7 @@ export const make = Effect.gen(function* () {
...(detail.comparison?.behindBy == null
? {}
: { behindBy: detail.comparison.behindBy }),
...(deployments === undefined ? {} : { deployments }),
}),
),
),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/PullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
PullRequestCheck,
PullRequestComment,
PullRequestCommit,
PullRequestDeployment,
PullRequestInvolvement,
PullRequestLabel,
PullRequestListFilters,
Expand Down Expand Up @@ -158,6 +159,8 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
readonly closedAt: string | null;
readonly reviewers: ReadonlyArray<PullRequestActor>;
readonly checks: ReadonlyArray<PullRequestCheck>;
/** Where the change is running, newest first. Absent from a read that could not ask. */
readonly deployments?: ReadonlyArray<PullRequestDeployment>;
readonly mergeCapabilities: PullRequestMergeCapabilities;
readonly viewerPermissions: PullRequestViewerPermissions;
/** Absent from a host that cannot compare the branch with its base, which is most of them. */
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Loading
Loading