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