diff --git a/README.md b/README.md index 5daaaba..c185dbf 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ lfc builds list # paginated; excludes torn_down/pending lfc builds list --mine --search checkout --all -n 50 -p 2 lfc builds get # status, PR, services + links, env overrides lfc builds get --manifest # include the stored manifest +lfc builds find --pr # resolve a PR/branch to a build (see below) lfc builds status # one-shot status (exit 1 if failed) lfc builds status --watch # live-updating until deployed/failed; exit 0/1/2 lfc builds redeploy [--watch] # redeploy everything @@ -90,6 +91,16 @@ lfc builds webhooks [--invoke] # webhook invocation history / trigger lfc builds open [--print] # open in the Lifecycle UI ``` +Don't have the UUID? Resolve one from a PR or branch instead of hunting through PR comments: + +```bash +lfc builds find --pr https://github.com/org/repo/pull/123 # PR URL is self-contained +lfc builds find --pr 123 --repo org/repo # bare number needs --repo +lfc builds find --branch my-branch --repo org/repo # branch needs --repo +``` + +Prints the resolved build (with `--json`: `{found, build, matches}` — `matches` lists every matching uuid, so more than one means ambiguous). Exit `0` found, `3` no build yet (e.g. the PR was just opened), `1` scan truncated before resolving. The CLI doesn't read local git — you supply the PR/branch (from `git`, `gh`, wherever), keeping it a pure Lifecycle client. + ### Env-var overrides (the “PR comment” overrides) ```bash diff --git a/src/commands/builds.ts b/src/commands/builds.ts index 158f7f4..6c7956e 100644 --- a/src/commands/builds.ts +++ b/src/commands/builds.ts @@ -5,6 +5,7 @@ import pc from 'picocolors'; import { openBrowser } from '../lib/auth.js'; import { runAction, type Ctx } from '../lib/context.js'; import { formatAge, link, parseDuration, printJson, renderTable, statusColor } from '../lib/output.js'; +import { parseSelector, pickBuild } from '../lib/resolve.js'; import { BUILD_TERMINAL_FAILURE, BUILD_TERMINAL_SUCCESS, type Build, type Deploy } from '../lib/types.js'; function buildUiUrl(ctx: Ctx, uuid: string): string | undefined { @@ -78,6 +79,38 @@ async function watchBuild(ctx: Ctx, uuid: string, intervalMs: number, timeoutMs: } } +/** Render a build's detail block (status, PR, services, env overrides). Shared by `get` and `find`. */ +function renderBuildDetail(ctx: Ctx, build: Build): void { + const pr = build.pullRequest; + const ui = buildUiUrl(ctx, build.uuid); + const fields: Array<[string, string]> = [ + ['status', `${statusColor(build.status)}${build.statusMessage ? pc.dim(` ${build.statusMessage}`) : ''}`], + ['namespace', build.namespace ?? ''], + ['kind', `${build.kind ?? 'environment'}${build.isStatic ? ' (static)' : ''}`], + ['sha', build.sha?.slice(0, 12) ?? ''], + ['pr', pr ? `${prLabel(build)} ${pc.dim(pr.title ?? '')}` : pc.dim('none')], + ['branch', pr?.branchName ?? ''], + ['author', pr?.githubLogin ?? ''], + ['updated', `${build.updatedAt ?? ''} ${pc.dim(formatAge(build.updatedAt))}`], + ['ui', ui ? link(ui) : ''], + ]; + process.stdout.write(`${pc.bold(build.uuid)}\n`); + for (const [k, v] of fields) { + if (v) process.stdout.write(` ${pc.dim(k.padEnd(10))} ${v}\n`); + } + const rows = serviceRows(build); + if (rows.length > 0) { + process.stdout.write(`\n${renderTable(['service', 'status', 'branch', 'url', 'updated'], rows)}\n`); + } + const runtimeEnv = build.commentRuntimeEnv ?? {}; + const initEnv = build.commentInitEnv ?? {}; + if (Object.keys(runtimeEnv).length || Object.keys(initEnv).length) { + process.stdout.write(`\n${pc.bold('env overrides')}\n`); + for (const [k, v] of Object.entries(runtimeEnv)) process.stdout.write(` ${k}=${v}\n`); + for (const [k, v] of Object.entries(initEnv)) process.stdout.write(` ${pc.dim('(init)')} ${k}=${v}\n`); + } +} + async function renderStatusOnce(ctx: Ctx, uuid: string): Promise { const build = await ctx.api.getBuild(uuid); const lines: string[] = []; @@ -144,34 +177,7 @@ export function registerBuildsCommands(program: Command): void { printJson(build); return; } - const pr = build.pullRequest; - const ui = buildUiUrl(ctx, build.uuid); - const fields: Array<[string, string]> = [ - ['status', `${statusColor(build.status)}${build.statusMessage ? pc.dim(` ${build.statusMessage}`) : ''}`], - ['namespace', build.namespace ?? ''], - ['kind', `${build.kind ?? 'environment'}${build.isStatic ? ' (static)' : ''}`], - ['sha', build.sha?.slice(0, 12) ?? ''], - ['pr', pr ? `${prLabel(build)} ${pc.dim(pr.title ?? '')}` : pc.dim('none')], - ['branch', pr?.branchName ?? ''], - ['author', pr?.githubLogin ?? ''], - ['updated', `${build.updatedAt ?? ''} ${pc.dim(formatAge(build.updatedAt))}`], - ['ui', ui ? link(ui) : ''], - ]; - process.stdout.write(`${pc.bold(build.uuid)}\n`); - for (const [k, v] of fields) { - if (v) process.stdout.write(` ${pc.dim(k.padEnd(10))} ${v}\n`); - } - const rows = serviceRows(build); - if (rows.length > 0) { - process.stdout.write(`\n${renderTable(['service', 'status', 'branch', 'url', 'updated'], rows)}\n`); - } - const runtimeEnv = build.commentRuntimeEnv ?? {}; - const initEnv = build.commentInitEnv ?? {}; - if (Object.keys(runtimeEnv).length || Object.keys(initEnv).length) { - process.stdout.write(`\n${pc.bold('env overrides')}\n`); - for (const [k, v] of Object.entries(runtimeEnv)) process.stdout.write(` ${k}=${v}\n`); - for (const [k, v] of Object.entries(initEnv)) process.stdout.write(` ${pc.dim('(init)')} ${k}=${v}\n`); - } + renderBuildDetail(ctx, build); if (opts.manifest && build.manifest) { process.stdout.write(`\n${pc.bold('manifest')}\n`); process.stdout.write(typeof build.manifest === 'string' ? `${build.manifest}\n` : `${JSON.stringify(build.manifest, null, 2)}\n`); @@ -179,6 +185,55 @@ export function registerBuildsCommands(program: Command): void { }) ); + builds + .command('find') + .description('Find a build by PR or branch and resolve it to a uuid (agent-friendly)') + .option('--pr ', 'GitHub PR URL, or a PR number (a bare number needs --repo)') + .option('--branch ', 'branch name (needs --repo)') + .option('--repo ', 'repository; required unless --pr is a full PR URL') + .action( + runAction(async (ctx, opts: { pr?: string; branch?: string; repo?: string }) => { + const selector = parseSelector(opts); + const target = + selector.prNumber !== undefined ? `${selector.repo}#${selector.prNumber}` : `${selector.repo}@${selector.branch}`; + const { matches, truncated } = await ctx.api.resolveBuild(selector); + const chosen = pickBuild(matches); + + if (!chosen) { + if (truncated) { + process.stderr.write( + `${pc.yellow('!')} No build matched ${target} in the pages scanned — results may be truncated. ` + + `Double-check the repo/branch, or read the uuid from the PR comment.\n` + ); + process.exitCode = 1; + } else { + process.stderr.write( + `${pc.dim(`No build found for ${target}. If the PR was just opened, Lifecycle may not have created the environment yet.`)}\n` + ); + process.exitCode = 3; + } + if (ctx.json) printJson({ found: false, target, truncated }); + return; + } + + // The list payload is trimmed; fetch the full build so output matches `builds get`. + const build = await ctx.api.getBuild(chosen.uuid); + const others = matches.filter((m) => m.uuid !== chosen.uuid).map((m) => m.uuid); + if (others.length > 0) { + process.stderr.write( + `${pc.yellow('!')} ${matches.length} builds matched ${target}; showing most recent ${pc.bold(chosen.uuid)}. Others: ${others.join(', ')}\n` + ); + } + if (ctx.json) { + // Uniform envelope (`found` always present) so callers can branch on it, + // and multiple matches are visible in JSON, not just on stderr. + printJson({ found: true, build, matches: matches.map((m) => m.uuid) }); + return; + } + renderBuildDetail(ctx, build); + }) + ); + builds .command('status ') .description('Show build status; --watch polls until it converges (exit 0 deployed, 1 failed, 2 timeout)') diff --git a/src/commands/llms.ts b/src/commands/llms.ts index 0bd32ea..ddfab05 100644 --- a/src/commands/llms.ts +++ b/src/commands/llms.ts @@ -105,6 +105,8 @@ Builds (preview environments): lfc builds list [--mine] [--search ] [--all] [-n ] [-p ] lfc builds get [--manifest] + lfc builds find --pr [--repo org/repo] # resolve a PR/branch to a build + lfc builds find --branch --repo org/repo # (see the recipe below) lfc builds status [--watch] # exit 0 deployed, 1 failed, 2 timeout lfc builds redeploy [--watch] lfc builds destroy --yes @@ -157,10 +159,17 @@ Static sites: ## Recipes for common agent tasks -Find the build for a PR or branch: +Find the build for a PR or branch (preferred — resolves straight to the build): - lfc builds list --search --json - # match on .branchName / .pullRequest fields; uuid is the key for everything else + lfc builds find --pr https://github.com/org/repo/pull/123 --json # PR URL is self-contained + lfc builds find --pr 123 --repo org/repo --json # bare number needs --repo + lfc builds find --branch my-branch --repo org/repo --json # branch needs --repo + # --json emits {found, build, matches}: build is the chosen one (most recent; live + # preferred over torn-down); matches lists ALL matching uuids (length > 1 = ambiguous). + # Exit codes: 0 = found 3 = no build yet (e.g. PR just opened, not built) + # 1 = scan truncated before resolving (narrow with --repo / read the PR comment) + # The CLI does not read local git — you supply the PR/branch (get it however you like, + # e.g. from git or gh). Fallback for fuzzy lookups: lfc builds list --search --json. Get the public URLs of a build's services: @@ -194,7 +203,8 @@ Publish a report or artifact for humans: headless machines); verify with lfc whoami. - Network errors / cannot reach the API -> wrong URL or the user is off VPN. Check the configured URL with lfc config get; ask the user to check VPN. -- Exit code 3 on a build command -> wrong or stale uuid; find the right one with +- Exit code 3 on a build command -> wrong or stale uuid; resolve the right one with + lfc builds find --pr (or --branch --repo org/repo), or search with lfc builds list --search . - Watch exited 2 (timeout) -> the deploy did not converge; investigate with pods list / services logs instead of blindly re-running redeploy. diff --git a/src/lib/api.ts b/src/lib/api.ts index 9bc013a..a0206ba 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,6 @@ import { getAccessToken } from './auth.js'; import type { Profile } from './config.js'; +import { matchBuilds, type Selector } from './resolve.js'; import type { ApiEnvelope, Build, @@ -122,6 +123,31 @@ export class ApiClient { return { items: env.data ?? [], pagination: env.metadata?.pagination }; } + /** + * Resolve a build by repo + PR number or branch, using the existing list + * endpoint (search narrows to the repo; matching is authoritative client-side). + * Pages until the repo's builds are exhausted or a page cap is hit; `truncated` + * signals the cap was reached with more pages left (so callers don't report a + * capped scan as a definitive "not found"). + */ + async resolveBuild( + selector: Selector, + opts: { limit?: number; maxPages?: number } = {} + ): Promise<{ matches: BuildListItem[]; truncated: boolean }> { + const limit = opts.limit ?? 100; + const maxPages = opts.maxPages ?? 20; + const matches: BuildListItem[] = []; + for (let page = 1; page <= maxPages; page++) { + const { items } = await this.listBuilds({ page, limit, search: selector.repo, exclude: '' }); + matches.push(...matchBuilds(items, selector)); + // A short (or empty) page is the last page — more robust than trusting totalPages. + if (items.length < limit) return { matches, truncated: false }; + // Full page and we've hit the cap: more results may remain unscanned. + if (page >= maxPages) return { matches, truncated: true }; + } + return { matches, truncated: false }; + } + async getBuild(uuid: string): Promise { const env = await this.request('GET', `/api/v2/builds/${encodeURIComponent(uuid)}`); return env.data as Build; diff --git a/src/lib/resolve.ts b/src/lib/resolve.ts new file mode 100644 index 0000000..22106fc --- /dev/null +++ b/src/lib/resolve.ts @@ -0,0 +1,115 @@ +import type { BuildListItem } from './types.js'; + +/** A normalized build selector: repo plus exactly one of prNumber or branch. */ +export interface Selector { + repo: string; + prNumber?: number; + branch?: string; +} + +/** Raw CLI flags for `builds find`. */ +export interface SelectorInput { + pr?: string; + branch?: string; + repo?: string; +} + +// Anchor github.com to a host boundary (start or after "//") so look-alikes +// like "notgithub.com/..." don't match. +const PR_URL_RE = /(?:^|\/\/)(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+?)\/pull\/(\d+)/i; +const REPO_RE = /^[^/\s]+\/[^/\s]+$/; +const TORN_DOWN = new Set(['torn_down', 'deleted']); + +function normalizeRepo(repo: string): string { + return repo.trim().replace(/\.git$/i, '').toLowerCase(); +} + +function assertRepo(repo: string): string { + const r = repo.trim(); + if (!REPO_RE.test(r)) throw new Error(`--repo must be in the form org/repo (got "${repo}")`); + return r; +} + +/** + * Parse a GitHub PR URL into {repo, prNumber}. Tolerates trailing path segments + * (/files, /commits), fragments and query strings. Returns null if not a PR URL. + * Punts (returns null): SSH remotes, `org/repo#123` shorthand, non-github hosts. + */ +export function parsePrUrl(input: string): { repo: string; prNumber: number } | null { + const cleaned = input.trim().split('#')[0]?.split('?')[0] ?? ''; + const m = PR_URL_RE.exec(cleaned); + if (!m) return null; + const [, org, name, num] = m; + if (!org || !name || !num) return null; + const repo = `${org}/${name.replace(/\.git$/i, '')}`; + return { repo, prNumber: Number(num) }; +} + +/** + * Normalize `builds find` flags into a Selector, or throw a friendly error. + * Rules: exactly one of --pr / --branch; a PR URL is self-contained; a bare PR + * number or a branch requires --repo. + */ +export function parseSelector(input: SelectorInput): Selector { + const hasPr = Boolean(input.pr && input.pr.trim()); + const hasBranch = Boolean(input.branch && input.branch.trim()); + if (hasPr === hasBranch) { + throw new Error('Provide exactly one of --pr or --branch'); + } + + if (hasPr) { + const raw = input.pr!.trim(); + const url = parsePrUrl(raw); + if (url) { + if (input.repo && normalizeRepo(input.repo) !== normalizeRepo(url.repo)) { + throw new Error(`--repo "${input.repo}" conflicts with the repo in the PR URL (${url.repo})`); + } + return { repo: url.repo, prNumber: url.prNumber }; + } + if (!/^\d+$/.test(raw)) { + throw new Error(`--pr must be a GitHub PR URL or a PR number (got "${raw}")`); + } + if (!input.repo) throw new Error('--pr requires --repo (or pass a full PR URL)'); + return { repo: assertRepo(input.repo), prNumber: Number(raw) }; + } + + if (!input.repo) throw new Error('--branch requires --repo '); + return { repo: assertRepo(input.repo), branch: input.branch!.trim() }; +} + +/** + * Filter builds to those matching the selector: exact repo (case-insensitive) + * AND the single provided key (prNumber XOR branch). Builds without a pullRequest + * never match. + */ +export function matchBuilds(builds: BuildListItem[], selector: Selector): BuildListItem[] { + const repo = normalizeRepo(selector.repo); + return builds.filter((b) => { + const pr = b.pullRequest; + if (!pr || !pr.fullName) return false; + if (pr.fullName.toLowerCase() !== repo) return false; + // repo is compared case-insensitively (GitHub owner/name are); branch is + // compared exactly, since git branch names are case-sensitive. + if (selector.prNumber !== undefined) return pr.pullRequestNumber === selector.prNumber; + return pr.branchName === selector.branch; + }); +} + +function updatedAtMs(b: BuildListItem): number { + const n = Date.parse(b.updatedAt ?? ''); + return Number.isNaN(n) ? 0 : n; +} + +/** + * Pick which matched build to report: prefer a live one over a torn-down one, + * then the most recently updated. Returns undefined for an empty list. + */ +export function pickBuild(matches: BuildListItem[]): BuildListItem | undefined { + if (matches.length === 0) return undefined; + return [...matches].sort((a, b) => { + const aTd = TORN_DOWN.has((a.status ?? '').toLowerCase()) ? 1 : 0; + const bTd = TORN_DOWN.has((b.status ?? '').toLowerCase()) ? 1 : 0; + if (aTd !== bTd) return aTd - bTd; + return updatedAtMs(b) - updatedAtMs(a); + })[0]; +} diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..0a57eac --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiClient, type ListResult } from '../src/lib/api.js'; +import type { Profile } from '../src/lib/config.js'; +import type { BuildListItem } from '../src/lib/types.js'; + +const PROFILE: Profile = { apiUrl: 'http://example.test', authEnabled: false }; + +function mk(uuid: string, pr: { fullName: string; pullRequestNumber?: number; branchName?: string } | null): BuildListItem { + return { + uuid, + status: 'deployed', + namespace: `env-${uuid}`, + updatedAt: '2026-01-01T00:00:00Z', + pullRequest: + pr === null + ? null + : { + id: 1, + title: 't', + githubLogin: 'x', + status: 'open', + labels: [], + fullName: pr.fullName, + pullRequestNumber: pr.pullRequestNumber ?? 1, + branchName: pr.branchName ?? 'main', + }, + } as BuildListItem; +} + +/** An ApiClient whose listBuilds is stubbed to serve fixed pages. */ +function clientWithPages(pages: BuildListItem[][]): ApiClient { + const client = new ApiClient('test', PROFILE); + client.listBuilds = async ({ page = 1 }): Promise> => ({ + items: pages[page - 1] ?? [], + pagination: { page } as never, + }); + return client; +} + +describe('ApiClient.resolveBuild', () => { + it('stops on a short page and returns matches (truncated=false)', async () => { + const client = clientWithPages([ + [mk('a', { fullName: 'acme/repo', branchName: 'x' }), mk('b', { fullName: 'acme/repo', branchName: 'y' })], + [mk('c', { fullName: 'acme/repo', branchName: 'z' })], + ]); + const { matches, truncated } = await client.resolveBuild({ repo: 'acme/repo', branch: 'z' }, { limit: 2, maxPages: 5 }); + expect(matches.map((m) => m.uuid)).toEqual(['c']); + expect(truncated).toBe(false); + }); + + it('collects matches spread across multiple full pages', async () => { + const client = clientWithPages([ + [mk('a', { fullName: 'acme/repo', pullRequestNumber: 7 }), mk('z', { fullName: 'other/repo', pullRequestNumber: 7 })], + [mk('c', { fullName: 'acme/repo', pullRequestNumber: 7 })], + ]); + const { matches } = await client.resolveBuild({ repo: 'acme/repo', prNumber: 7 }, { limit: 2, maxPages: 5 }); + expect(matches.map((m) => m.uuid).sort()).toEqual(['a', 'c']); + }); + + it('flags truncated when the page cap is hit with full pages', async () => { + const client = clientWithPages([ + [mk('a', { fullName: 'acme/repo', pullRequestNumber: 1 }), mk('b', { fullName: 'acme/repo', pullRequestNumber: 2 })], + [mk('c', { fullName: 'acme/repo', pullRequestNumber: 3 }), mk('d', { fullName: 'acme/repo', pullRequestNumber: 4 })], + ]); + const { matches, truncated } = await client.resolveBuild({ repo: 'acme/repo', prNumber: 99 }, { limit: 2, maxPages: 2 }); + expect(matches).toEqual([]); + expect(truncated).toBe(true); + }); + + it('handles an empty first page', async () => { + const client = clientWithPages([[]]); + const { matches, truncated } = await client.resolveBuild({ repo: 'acme/repo', prNumber: 1 }, { limit: 2, maxPages: 5 }); + expect(matches).toEqual([]); + expect(truncated).toBe(false); + }); +}); diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts new file mode 100644 index 0000000..00e4cad --- /dev/null +++ b/tests/resolve.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { matchBuilds, parsePrUrl, parseSelector, pickBuild } from '../src/lib/resolve.js'; +import type { BuildListItem } from '../src/lib/types.js'; + +function mk(partial: { + uuid: string; + status?: string; + updatedAt?: string; + pr?: { fullName?: string; pullRequestNumber?: number; branchName?: string } | null; +}): BuildListItem { + return { + uuid: partial.uuid, + status: partial.status ?? 'deployed', + namespace: `env-${partial.uuid}`, + updatedAt: partial.updatedAt, + pullRequest: + partial.pr === null + ? null + : { + id: 1, + title: 't', + githubLogin: 'octocat', + status: 'open', + labels: [], + fullName: partial.pr?.fullName ?? 'acme/storefront', + pullRequestNumber: partial.pr?.pullRequestNumber ?? 1, + branchName: partial.pr?.branchName ?? 'main', + }, + } as BuildListItem; +} + +describe('parsePrUrl', () => { + it('parses a plain PR URL', () => { + expect(parsePrUrl('https://github.com/acme/storefront/pull/123')).toEqual({ + repo: 'acme/storefront', + prNumber: 123, + }); + }); + + it('tolerates trailing path, fragment and query', () => { + expect(parsePrUrl('https://github.com/acme/storefront/pull/123/files?w=1#r99')).toEqual({ + repo: 'acme/storefront', + prNumber: 123, + }); + }); + + it('strips a .git suffix on the repo', () => { + expect(parsePrUrl('https://github.com/acme/storefront.git/pull/7')).toEqual({ + repo: 'acme/storefront', + prNumber: 7, + }); + }); + + it('returns null for non-PR / unsupported inputs', () => { + expect(parsePrUrl('acme/storefront#123')).toBeNull(); + expect(parsePrUrl('git@github.com:acme/storefront.git')).toBeNull(); + expect(parsePrUrl('https://gitlab.com/acme/storefront/pull/1')).toBeNull(); + expect(parsePrUrl('just-a-branch')).toBeNull(); + }); + + it('does not match github.com look-alike hosts', () => { + expect(parsePrUrl('https://notgithub.com/acme/storefront/pull/1')).toBeNull(); + expect(parsePrUrl('https://github.com.evil.example/acme/storefront/pull/1')).toBeNull(); + }); +}); + +describe('parseSelector', () => { + it('resolves a self-contained PR URL without --repo', () => { + expect(parseSelector({ pr: 'https://github.com/acme/storefront/pull/9' })).toEqual({ + repo: 'acme/storefront', + prNumber: 9, + }); + }); + + it('resolves a bare PR number with --repo', () => { + expect(parseSelector({ pr: '42', repo: 'acme/storefront' })).toEqual({ + repo: 'acme/storefront', + prNumber: 42, + }); + }); + + it('resolves a branch with --repo', () => { + expect(parseSelector({ branch: 'feat/x', repo: 'acme/storefront' })).toEqual({ + repo: 'acme/storefront', + branch: 'feat/x', + }); + }); + + it('rejects neither / both selectors', () => { + expect(() => parseSelector({})).toThrow(/exactly one/); + expect(() => parseSelector({ pr: '1', branch: 'b', repo: 'a/b' })).toThrow(/exactly one/); + }); + + it('requires --repo for a bare number or a branch', () => { + expect(() => parseSelector({ pr: '42' })).toThrow(/--repo/); + expect(() => parseSelector({ branch: 'feat/x' })).toThrow(/--repo/); + }); + + it('rejects an invalid --repo and a non-URL non-number --pr', () => { + expect(() => parseSelector({ pr: '42', repo: 'not-a-repo' })).toThrow(/org\/repo/); + expect(() => parseSelector({ pr: 'garbage', repo: 'a/b' })).toThrow(/PR URL or a PR number/); + }); + + it('rejects a --repo that conflicts with the PR URL', () => { + expect(() => parseSelector({ pr: 'https://github.com/acme/storefront/pull/9', repo: 'other/repo' })).toThrow( + /conflicts/ + ); + }); +}); + +describe('matchBuilds', () => { + const builds = [ + mk({ uuid: 'a', pr: { fullName: 'acme/storefront', pullRequestNumber: 10, branchName: 'feat/x' } }), + mk({ uuid: 'b', pr: { fullName: 'acme/storefront', pullRequestNumber: 11, branchName: 'feat/y' } }), + mk({ uuid: 'c', pr: { fullName: 'other/repo', pullRequestNumber: 10, branchName: 'feat/x' } }), + mk({ uuid: 'd', pr: null }), + ]; + + it('matches on repo + PR number only', () => { + expect(matchBuilds(builds, { repo: 'acme/storefront', prNumber: 10 }).map((b) => b.uuid)).toEqual(['a']); + }); + + it('matches on repo + branch only', () => { + expect(matchBuilds(builds, { repo: 'acme/storefront', branch: 'feat/y' }).map((b) => b.uuid)).toEqual(['b']); + }); + + it('is case-insensitive on repo', () => { + expect(matchBuilds(builds, { repo: 'ACME/StoreFront', prNumber: 11 }).map((b) => b.uuid)).toEqual(['b']); + }); + + it('never matches a build without a pullRequest', () => { + expect(matchBuilds([mk({ uuid: 'd', pr: null })], { repo: 'acme/storefront', prNumber: 1 })).toEqual([]); + }); + + it('does not match the wrong repo even when the number/branch coincide', () => { + expect(matchBuilds(builds, { repo: 'acme/storefront', branch: 'feat/x' }).map((b) => b.uuid)).toEqual(['a']); + }); +}); + +describe('pickBuild', () => { + it('returns undefined for no matches', () => { + expect(pickBuild([])).toBeUndefined(); + }); + + it('prefers a live build over a torn-down one', () => { + const picked = pickBuild([ + mk({ uuid: 'old-live', status: 'deployed', updatedAt: '2026-01-01T00:00:00Z' }), + mk({ uuid: 'new-dead', status: 'torn_down', updatedAt: '2026-06-01T00:00:00Z' }), + ]); + expect(picked?.uuid).toBe('old-live'); + }); + + it('picks the most recent among live builds', () => { + const picked = pickBuild([ + mk({ uuid: 'older', updatedAt: '2026-01-01T00:00:00Z' }), + mk({ uuid: 'newer', updatedAt: '2026-06-01T00:00:00Z' }), + ]); + expect(picked?.uuid).toBe('newer'); + }); + + it('sorts builds with missing/invalid dates last', () => { + const picked = pickBuild([mk({ uuid: 'nodate' }), mk({ uuid: 'dated', updatedAt: '2026-01-01T00:00:00Z' })]); + expect(picked?.uuid).toBe('dated'); + }); +});