diff --git a/tools/deno/api-diff.ts b/tools/deno/api-diff.ts index 811e944af..6fc38cf60 100755 --- a/tools/deno/api-diff.ts +++ b/tools/deno/api-diff.ts @@ -36,12 +36,13 @@ type DiffTarget = { const SPEC_DIR_URL = (commit: string) => `https://api.github.com/repos/oxidecomputer/omicron/contents/openapi/nexus?ref=${commit}` -const SPEC_RAW_URL = (commit: string, filename: string) => - `https://raw.githubusercontent.com/oxidecomputer/omicron/${commit}/openapi/nexus/${filename}` +const SPEC_RAW_URL = (ref: string, path: string) => + `https://raw.githubusercontent.com/oxidecomputer/omicron/${ref}/${path}` async function resolveCommit(ref?: string | number): Promise { if (ref === undefined) return resolveCommit(await pickPr()) if (typeof ref === 'number') { + console.error(`Resolving PR #${ref} to commit...`) const query = `{ repository(owner: "oxidecomputer", name: "omicron") { pullRequest(number: ${ref}) { headRefOid } @@ -50,27 +51,67 @@ async function resolveCommit(ref?: string | number): Promise { const pr = await $`gh api graphql -f query=${query}`.json() return pr.data.repository.pullRequest.headRefOid } - return ref + // Full SHA is already resolved + if (/^[0-9a-f]{40}$/.test(ref)) return ref + // Resolve branches, tags, and short SHAs to a full commit SHA so the + // cache directory is keyed by immutable commit, not a moving ref + console.error(`Resolving ${ref} to commit...`) + try { + const sha = await $`gh api repos/oxidecomputer/omicron/commits/${ref} --jq .sha` + .stderr('null') + .text() + return sha.trim() + } catch { + throw new Error(`Ref '${ref}' not found in oxidecomputer/omicron`) + } } +/** 5 or fewer digits is a PR number; longer digit strings are short SHAs */ const normalizeRef = (ref?: string | number) => - typeof ref === 'string' && /^\d+$/.test(ref) ? parseInt(ref, 10) : ref + typeof ref === 'string' && /^\d{1,5}$/.test(ref) ? parseInt(ref, 10) : ref -async function getLatestSchema(commit: string) { - const contents = await $`gh api ${SPEC_DIR_URL(commit)}`.json() - const latestLink = contents.find((f: { name: string }) => f.name === 'nexus-latest.json') - if (!latestLink) throw new Error('nexus-latest.json not found') - return (await fetch(latestLink.download_url).then((r) => r.text())).trim() -} +const LEGACY_SPEC_PATH = 'openapi/nexus.json' -/** When diffing a single commit, we diff its latest schema against the previous one */ -async function getLatestAndPreviousSchema(commit: string) { - const contents = await $`gh api ${SPEC_DIR_URL(commit)}`.json() +async function listSchemaDir(ref: string) { + try { + return await $`gh api ${SPEC_DIR_URL(ref)}`.stderr('null').json() + } catch { + return null + } +} +async function getLatestSchema(ref: string) { + const contents = await listSchemaDir(ref) + if (!contents) { + console.error(`No openapi/nexus/ dir at ${ref}, falling back to ${LEGACY_SPEC_PATH}`) + return LEGACY_SPEC_PATH + } + const schemaFiles = contents + .map((f: { name: string }) => f.name) + .filter((n: string) => n.startsWith('nexus-')) + .sort() const latestLink = contents.find((f: { name: string }) => f.name === 'nexus-latest.json') - if (!latestLink) throw new Error('nexus-latest.json not found') + if (!latestLink) { + throw new Error( + `nexus-latest.json not found at ref '${ref}'. ` + + `Available schemas: ${schemaFiles.join(', ') || '(none)'}` + ) + } const latest = (await fetch(latestLink.download_url).then((r) => r.text())).trim() + return `openapi/nexus/${latest}` +} + +/** When diffing a single ref, we diff its latest schema against the previous one */ +async function getLatestAndPreviousSchema(ref: string) { + const contents = await listSchemaDir(ref) + if (!contents) { + throw new Error( + `No openapi/nexus/ dir at ref '${ref}'. ` + + `Single-ref mode requires the versioned schema directory.` + ) + } + const latestLink = contents.find((f: { name: string }) => f.name === 'nexus-latest.json') const schemaFiles = contents .filter( (f: { name: string }) => f.name.startsWith('nexus-') && f.name !== 'nexus-latest.json' @@ -78,11 +119,23 @@ async function getLatestAndPreviousSchema(commit: string) { .map((f: { name: string }) => f.name) .sort() + if (!latestLink) { + throw new Error( + `nexus-latest.json not found at ref '${ref}'. ` + + `Available schemas: ${schemaFiles.join(', ') || '(none)'}` + ) + } + const latest = (await fetch(latestLink.download_url).then((r) => r.text())).trim() + const latestIndex = schemaFiles.indexOf(latest) - if (latestIndex === -1) throw new Error(`Latest schema ${latest} not found in dir`) - if (latestIndex === 0) throw new Error('No previous schema version found') + if (latestIndex === -1) + throw new Error(`Latest schema ${latest} not found in dir at ref '${ref}'`) + if (latestIndex === 0) throw new Error(`No previous schema version found at ref '${ref}'`) - return { previous: schemaFiles[latestIndex - 1], latest } + return { + previous: `openapi/nexus/${schemaFiles[latestIndex - 1]}`, + latest: `openapi/nexus/${latest}`, + } } async function resolveTarget(ref1?: string | number, ref2?: string): Promise { @@ -90,24 +143,25 @@ async function resolveTarget(ref1?: string | number, ref2?: string): Promise r.text()) + const resp = await fetch(SPEC_RAW_URL(commit, specFilename)) + if (!resp.ok) { + throw new Error( + `Failed to download ${specFilename} at ${commit}: ${resp.status} ${resp.statusText}` + ) + } + const content = await resp.text() await Deno.writeTextFile(schemaPath, content) } return schemaPath @@ -155,7 +215,7 @@ async function runDiff( // use -L to set labels, extracting version from spec filename (e.g., nexus-2026010300.0.0-7599dd.json) const getVersion = (spec: string) => - spec.match(/nexus-([^.]+\.[^.]+\.[^.]+)/)?.[1] ?? spec + spec.match(/nexus-([^.]+\.[^.]+\.[^.]+)/)?.[1] ?? 'unversioned' const baseLabel = `a/${getVersion(baseVersion)}/${filename}` const headLabel = `b/${getVersion(headVersion)}/${filename}` // diff exits 1 when files differ, so noThrow() to avoid breaking the pipe @@ -170,9 +230,8 @@ await new Command() Arguments: No args Interactive PR picker - PR number (e.g., 1234) - Commit SHA - Two refs (commits or PRs), compare latest schema on each + PR number, commit SHA, branch, or tag + Two refs, compare latest schema on each Dependencies: - Deno @@ -193,22 +252,27 @@ Dependencies: }) .arguments('[ref1:string] [ref2:string]') .action(async (options, ref?: string, ref2?: string) => { - const target = await resolveTarget(ref, ref2) - const force = options.force ?? false + try { + const target = await resolveTarget(ref, ref2) + const force = options.force ?? false - const [baseSchema, headSchema] = await Promise.all([ - ensureSchema(target.baseCommit, target.baseSchema, force), - ensureSchema(target.headCommit, target.headSchema, force), - ]) - - if (options.format === 'schema') { - await runDiff(baseSchema, headSchema, target.baseSchema, target.headSchema) - } else { - const [baseClient, headClient] = await Promise.all([ - ensureClient(baseSchema, force), - ensureClient(headSchema, force), + const [baseSchema, headSchema] = await Promise.all([ + ensureSchema(target.baseCommit, target.baseSchema, force), + ensureSchema(target.headCommit, target.headSchema, force), ]) - await runDiff(baseClient, headClient, target.baseSchema, target.headSchema) + + if (options.format === 'schema') { + await runDiff(baseSchema, headSchema, target.baseSchema, target.headSchema) + } else { + const [baseClient, headClient] = await Promise.all([ + ensureClient(baseSchema, force), + ensureClient(headSchema, force), + ]) + await runDiff(baseClient, headClient, target.baseSchema, target.headSchema) + } + } catch (e) { + console.error(`error: ${e instanceof Error ? e.message : e}`) + Deno.exit(1) } }) .parse(Deno.args)