Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uuid> # status, PR, services + links, env overrides
lfc builds get <uuid> --manifest # include the stored manifest
lfc builds find --pr <url> # resolve a PR/branch to a build (see below)
lfc builds status <uuid> # one-shot status (exit 1 if failed)
lfc builds status <uuid> --watch # live-updating until deployed/failed; exit 0/1/2
lfc builds redeploy <uuid> [--watch] # redeploy everything
Expand All @@ -90,6 +91,16 @@ lfc builds webhooks <uuid> [--invoke] # webhook invocation history / trigger
lfc builds open <uuid> [--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
Expand Down
111 changes: 83 additions & 28 deletions src/commands/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Build> {
const build = await ctx.api.getBuild(uuid);
const lines: string[] = [];
Expand Down Expand Up @@ -144,41 +177,63 @@ 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`);
}
})
);

builds
.command('find')
.description('Find a build by PR or branch and resolve it to a uuid (agent-friendly)')
.option('--pr <url|number>', 'GitHub PR URL, or a PR number (a bare number needs --repo)')
.option('--branch <name>', 'branch name (needs --repo)')
.option('--repo <org/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 <uuid>')
.description('Show build status; --watch polls until it converges (exit 0 deployed, 1 failed, 2 timeout)')
Expand Down
18 changes: 14 additions & 4 deletions src/commands/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ Builds (preview environments):

lfc builds list [--mine] [--search <text>] [--all] [-n <limit>] [-p <page>]
lfc builds get <uuid> [--manifest]
lfc builds find --pr <url|number> [--repo org/repo] # resolve a PR/branch to a build
lfc builds find --branch <name> --repo org/repo # (see the recipe below)
lfc builds status <uuid> [--watch] # exit 0 deployed, 1 failed, 2 timeout
lfc builds redeploy <uuid> [--watch]
lfc builds destroy <uuid> --yes
Expand Down Expand Up @@ -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 <branch-or-pr-text> --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 <text> --json.

Get the public URLs of a build's services:

Expand Down Expand Up @@ -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 <url> (or --branch <name> --repo org/repo), or search with
lfc builds list --search <text>.
- Watch exited 2 (timeout) -> the deploy did not converge; investigate with
pods list / services logs instead of blindly re-running redeploy.
Expand Down
26 changes: 26 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Build> {
const env = await this.request<Build>('GET', `/api/v2/builds/${encodeURIComponent(uuid)}`);
return env.data as Build;
Expand Down
115 changes: 115 additions & 0 deletions src/lib/resolve.ts
Original file line number Diff line number Diff line change
@@ -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 <number> requires --repo <org/repo> (or pass a full PR URL)');
return { repo: assertRepo(input.repo), prNumber: Number(raw) };
}

if (!input.repo) throw new Error('--branch requires --repo <org/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];
}
Loading
Loading