Skip to content

Commit 2f5e157

Browse files
committed
feat(cli): surface preview deployment links in the PR header
getChecks now fetches the commit's deployments via a single gh api graphql query (commit.deployments + latestStatus), in parallel with check-runs, and resolves one link per environment — the latest successful deployment with an https URL (mirrors hosted Stage's resolveDeploymentLinks). The header's already-vendored deployment button/popover renders them with no UI change. Replaces the previous always-empty deploymentLinks (and the inaccurate "needs a GitHub App" note — the Deployments data is readable via gh).
1 parent 83886cf commit 2f5e157

2 files changed

Lines changed: 161 additions & 16 deletions

File tree

packages/cli/src/__tests__/pull-request.routes.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ const MERGE_JSON = JSON.stringify({
110110
},
111111
},
112112
});
113+
// Deployments GraphQL response (newest-first). Exercises dedupe-by-environment,
114+
// skipping non-success and non-https/null URLs.
115+
const DEPLOYMENTS_JSON = JSON.stringify({
116+
data: {
117+
repository: {
118+
object: {
119+
deployments: {
120+
nodes: [
121+
{
122+
environment: "Preview",
123+
latestStatus: { state: "SUCCESS", environmentUrl: "https://preview-2.example.app" },
124+
},
125+
{
126+
environment: "Preview",
127+
latestStatus: { state: "SUCCESS", environmentUrl: "https://preview-1.example.app" },
128+
},
129+
{
130+
environment: "Production",
131+
latestStatus: { state: "SUCCESS", environmentUrl: "https://prod.example.app" },
132+
},
133+
{
134+
environment: "Staging",
135+
latestStatus: { state: "FAILURE", environmentUrl: "https://staging.example.app" },
136+
},
137+
{ environment: "NoUrl", latestStatus: { state: "SUCCESS", environmentUrl: null } },
138+
],
139+
},
140+
},
141+
},
142+
},
143+
});
113144

114145
beforeEach(async () => {
115146
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-pr-routes-"));
@@ -143,6 +174,7 @@ async function writeFakeGh(fixtures: {
143174
reviews?: string;
144175
checks?: string;
145176
merge?: string;
177+
deployments?: string;
146178
}): Promise<void> {
147179
const dir = path.join(binDir, "fixtures");
148180
await fs.mkdir(dir, { recursive: true });
@@ -155,13 +187,15 @@ async function writeFakeGh(fixtures: {
155187
write("reviews.json", fixtures.reviews),
156188
write("checks.json", fixtures.checks),
157189
write("merge.json", fixtures.merge),
190+
write("deployments.json", fixtures.deployments),
158191
]);
159192
const script = `#!/bin/sh
160193
dir="${dir}"
161194
emit() { [ -f "$dir/$1" ] && cat "$dir/$1" || exit 1; }
162195
all="$*"
163196
if [ "$1" = "pr" ] && [ "$2" = "view" ]; then emit pr.json
164-
elif [ "$1" = "api" ] && [ "$2" = "graphql" ]; then emit merge.json
197+
elif [ "$1" = "api" ] && [ "$2" = "graphql" ]; then
198+
case "$all" in *deployments*) emit deployments.json ;; *) emit merge.json ;; esac
165199
elif [ "$1" = "api" ]; then
166200
case "$all" in
167201
*check-runs*) emit checks.json ;;
@@ -288,6 +322,23 @@ describe("pull-request API", () => {
288322
});
289323
});
290324

325+
it("returns one deployment link per environment (latest success, https only)", async () => {
326+
await writeFakeGh({ checks: CHECKS_JSON, deployments: DEPLOYMENTS_JSON });
327+
const runId = insertRun(GITHUB_ORIGIN);
328+
const res = await request(
329+
await start(),
330+
`/api/runs/${runId}/pull-request/checks?headSha=${SHA}`,
331+
);
332+
expect(res.status).toBe(200);
333+
const body = JSON.parse(res.body) as ChecksResponse;
334+
// Preview deduped to the newest success; Production kept; Staging (failure)
335+
// and NoUrl (null url) dropped.
336+
expect(body.deploymentLinks).toEqual([
337+
{ environment: "Preview", url: "https://preview-2.example.app" },
338+
{ environment: "Production", url: "https://prod.example.app" },
339+
]);
340+
});
341+
291342
it("rejects a checks request without a valid headSha", async () => {
292343
await writeFakeGh({ checks: CHECKS_JSON });
293344
const runId = insertRun(GITHUB_ORIGIN);

packages/cli/src/github/pull-request.ts

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
CHECK_ITEM_SOURCE,
44
type CheckItem,
55
type ChecksResponse,
6+
type DeploymentLink,
67
type GitHubPullRequest,
78
type GitHubUser,
89
type MergeStatusInfo,
@@ -206,20 +207,11 @@ function deriveCiState(items: CheckItem[]): ChecksResponse["state"] {
206207
return anyPending ? PULL_REQUEST_CI_STATUS.PENDING : PULL_REQUEST_CI_STATUS.SUCCESS;
207208
}
208209

209-
/**
210-
* CI check runs for `headSha`. Deployment links require a GitHub App
211-
* integration the CLI doesn't have, so `deploymentLinks` is always empty here.
212-
*/
213-
export async function getChecks(
210+
async function getCheckRunItems(
214211
repoRoot: string,
215212
repo: GitHubRepo,
216213
headSha: string,
217-
): Promise<ChecksResponse> {
218-
const empty: ChecksResponse = {
219-
state: PULL_REQUEST_CI_STATUS.NONE,
220-
items: [],
221-
deploymentLinks: [],
222-
};
214+
): Promise<CheckItem[]> {
223215
try {
224216
// `--slurp` wraps every page into one JSON array (`[{page}, {page}, …]`);
225217
// without it, `--paginate` concatenates raw page objects, which isn't valid
@@ -234,14 +226,116 @@ export async function getChecks(
234226
repoRoot,
235227
);
236228
const parsed = z.array(GhCheckRunsSchema).safeParse(JSON.parse(stdout));
237-
if (!parsed.success) return empty;
238-
const items = parsed.data.flatMap((page) => page.check_runs).map(toCheckItem);
239-
return { state: deriveCiState(items), items, deploymentLinks: [] };
229+
if (!parsed.success) return [];
230+
return parsed.data.flatMap((page) => page.check_runs).map(toCheckItem);
240231
} catch {
241-
return empty;
232+
return [];
242233
}
243234
}
244235

236+
// ─── Deployments ──────────────────────────────────────────────────────────────
237+
238+
// Fetch the commit's deployments + each one's latest status in a single query,
239+
// newest-first, so dedupe-by-environment below keeps the most recent per env.
240+
const DEPLOYMENTS_QUERY = `query GetDeployments($owner: String!, $repo: String!, $sha: GitObjectID!) {
241+
repository(owner: $owner, name: $repo) {
242+
object(oid: $sha) {
243+
... on Commit {
244+
deployments(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) {
245+
nodes { environment latestStatus { state environmentUrl } }
246+
}
247+
}
248+
}
249+
}
250+
}`;
251+
252+
const GhDeploymentsSchema = z.object({
253+
data: z.object({
254+
repository: z
255+
.object({
256+
object: z
257+
.object({
258+
deployments: z.object({
259+
nodes: z.array(
260+
z.object({
261+
environment: z.string(),
262+
latestStatus: z
263+
.object({ state: z.string(), environmentUrl: z.string().nullable() })
264+
.nullable(),
265+
}),
266+
),
267+
}),
268+
})
269+
.nullable(),
270+
})
271+
.nullable(),
272+
}),
273+
});
274+
275+
/**
276+
* Preview/deployment links for `headSha`: one per environment, keeping the
277+
* latest successful deployment with an https URL (mirrors hosted Stage's
278+
* resolveDeploymentLinks). Returns [] on any failure so checks still render.
279+
*/
280+
async function getDeploymentLinks(
281+
repoRoot: string,
282+
repo: GitHubRepo,
283+
headSha: string,
284+
): Promise<DeploymentLink[]> {
285+
try {
286+
const stdout = await gh(
287+
[
288+
"api",
289+
"graphql",
290+
"-f",
291+
`query=${DEPLOYMENTS_QUERY}`,
292+
"-F",
293+
`owner=${repo.owner}`,
294+
"-F",
295+
`repo=${repo.repo}`,
296+
"-F",
297+
`sha=${headSha}`,
298+
],
299+
repoRoot,
300+
);
301+
const parsed = GhDeploymentsSchema.safeParse(JSON.parse(stdout));
302+
if (!parsed.success) return [];
303+
const nodes = parsed.data.data.repository?.object?.deployments.nodes ?? [];
304+
const byEnvironment = new Map<string, DeploymentLink>();
305+
for (const node of nodes) {
306+
const url = node.latestStatus?.environmentUrl;
307+
if (
308+
node.latestStatus?.state === "SUCCESS" &&
309+
url &&
310+
url.startsWith("https://") &&
311+
!byEnvironment.has(node.environment)
312+
) {
313+
byEnvironment.set(node.environment, { environment: node.environment, url });
314+
}
315+
}
316+
return [...byEnvironment.values()];
317+
} catch {
318+
return [];
319+
}
320+
}
321+
322+
/**
323+
* CI check runs plus preview-deployment links for `headSha`. Runs the two gh
324+
* reads in parallel; each degrades to empty independently so a failure in one
325+
* never blanks the other.
326+
*/
327+
export async function getChecks(
328+
repoRoot: string,
329+
repo: GitHubRepo,
330+
headSha: string,
331+
): Promise<ChecksResponse> {
332+
const [items, deploymentLinks] = await Promise.all([
333+
getCheckRunItems(repoRoot, repo, headSha),
334+
getDeploymentLinks(repoRoot, repo, headSha),
335+
]);
336+
return { state: deriveCiState(items), items, deploymentLinks };
337+
}
338+
245339
// ─── Reviews ────────────────────────────────────────────────────────────────
246340

247341
// REST reviews are returned oldest-first, so iterating and overwriting per login

0 commit comments

Comments
 (0)