Skip to content

Commit ae965cf

Browse files
authored
Rewrite api-diff for new versioned API (#2986)
* update api-diff tool for versioned API * codex simplifications * make things easier to understand * shorter help
1 parent 8765647 commit ae965cf

File tree

1 file changed

+152
-104
lines changed

1 file changed

+152
-104
lines changed

tools/deno/api-diff.ts

Lines changed: 152 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -8,115 +8,131 @@
88
* Copyright Oxide Computer Company
99
*/
1010
import { exists } from 'https://deno.land/std@0.208.0/fs/mod.ts'
11-
import { parseArgs } from 'https://deno.land/std@0.220.1/cli/mod.ts'
1211
import { $ } from 'https://deno.land/x/dax@0.39.1/mod.ts'
12+
import { Command, ValidationError } from 'jsr:@cliffy/command@1.0.0-rc.8'
1313

14-
const HELP = `
15-
Display changes to API client caused by a given Omicron PR. Works by downloading
16-
the OpenAPI spec before and after, generating clients in temp dirs, and diffing.
17-
18-
Dependencies:
19-
- Deno (which you have if you're seeing this message)
20-
- GitHub CLI (gh)
21-
- Optional: delta diff pager https://dandavison.github.io/delta/
22-
23-
Usage:
24-
./tools/deno/api-diff.ts [-f] [PR number or commit SHA]
25-
./tools/deno/api-diff.ts [-f] [commit SHA] [commit SHA]
26-
./tools/deno/api-diff.ts -h
27-
28-
Flags:
29-
-f, --force Download spec and regen client even if dir already exists
30-
-h, --help Show this help message
31-
32-
Parameters:
33-
PR number or commit SHA: If left out, interactive picker is shown.
34-
If two positional arguments are passed, we assume they are commits.
35-
`.trim()
36-
37-
function printHelpAndExit(): never {
38-
console.info(HELP)
39-
Deno.exit()
40-
}
41-
42-
// have to do this this way because I couldn't figure out how to get
43-
// my stupid bash function to show up here. I'm sure it's possible
44-
async function pickPr() {
45-
const listPRs = () =>
46-
$`gh pr list -R oxidecomputer/omicron --limit 100
47-
--json number,title,updatedAt,author
14+
// fzf picker keeps UX quick without requiring people to wire up shell helpers
15+
async function pickPr(): Promise<number> {
16+
const prNum = await $`gh pr list -R oxidecomputer/omicron --limit 100
17+
--json number,title,updatedAt,author
4818
--template '{{range .}}{{tablerow .number .title .author.name (timeago .updatedAt)}}{{end}}'`
49-
const picker = () => $`fzf --height 25% --reverse`
50-
const cut = () => $`cut -f1 -d ' '`
51-
52-
const prNum = await listPRs().pipe(picker()).pipe(cut()).text()
53-
if (!/^\d+$/.test(prNum)) {
19+
.pipe($`fzf --height 25% --reverse`)
20+
.pipe($`cut -f1 -d ' '`)
21+
.text()
22+
if (!/^\d+$/.test(prNum))
5423
throw new Error(`Error picking PR. Expected number, got '${prNum}'`)
55-
}
56-
return prNum
24+
return parseInt(prNum, 10)
5725
}
5826

59-
async function getCommitRange(
60-
args: Array<string | number>
61-
): Promise<{ base: string; head: string }> {
62-
// if there are two or more args, assume two commits
63-
if (args.length >= 2) {
64-
return { base: args[0].toString(), head: args[1].toString() }
65-
}
27+
// because the schema files change, in order to specify a schema you need both a
28+
// commit and a filename
29+
type DiffTarget = {
30+
baseCommit: string
31+
baseSchema: string
32+
headCommit: string
33+
headSchema: string
34+
}
6635

67-
// if there are no args or the arg is a number, we're talking about a PR
68-
if (args.length === 0 || typeof args[0] === 'number') {
69-
const prNum = args[0] || (await pickPr())
70-
// This graphql thing is absurd, but the idea is to use the branch point as
71-
// the base, i.e., the parent of the first commit. If we use the base ref
72-
// (e.g., main) directly, we get the current state of main, which means the
73-
// diff will reflect both the current PR and any changes made on main since
74-
// it branched off.
36+
const SPEC_DIR_URL = (commit: string) =>
37+
`https://api.github.com/repos/oxidecomputer/omicron/contents/openapi/nexus?ref=${commit}`
38+
39+
const SPEC_RAW_URL = (commit: string, filename: string) =>
40+
`https://raw.githubusercontent.com/oxidecomputer/omicron/${commit}/openapi/nexus/${filename}`
41+
42+
async function resolveCommit(ref?: string | number): Promise<string> {
43+
if (ref === undefined) return resolveCommit(await pickPr())
44+
if (typeof ref === 'number') {
7545
const query = `{
7646
repository(owner: "oxidecomputer", name: "omicron") {
77-
pullRequest(number: ${prNum}) {
78-
headRefOid
79-
commits(first: 1) {
80-
nodes {
81-
commit {
82-
parents(first: 1) { nodes { oid } }
83-
}
84-
}
85-
}
86-
}
47+
pullRequest(number: ${ref}) { headRefOid }
8748
}
8849
}`
8950
const pr = await $`gh api graphql -f query=${query}`.json()
90-
const head = pr.data.repository.pullRequest.headRefOid
91-
const base = pr.data.repository.pullRequest.commits.nodes[0].commit.parents.nodes[0].oid
92-
return { base, head }
51+
return pr.data.repository.pullRequest.headRefOid
9352
}
53+
return ref
54+
}
55+
56+
const normalizeRef = (ref?: string | number) =>
57+
typeof ref === 'string' && /^\d+$/.test(ref) ? parseInt(ref, 10) : ref
9458

95-
// otherwise assume it's a commit
96-
const head = args[0]
97-
const parents =
98-
await $`gh api repos/oxidecomputer/omicron/commits/${head} --jq '.parents'`.json()
99-
if (parents.length > 1) throw new Error(`Commit has multiple parents:`)
100-
return { base: parents[0].sha, head }
59+
async function getLatestSchema(commit: string) {
60+
const contents = await $`gh api ${SPEC_DIR_URL(commit)}`.json()
61+
const latestLink = contents.find((f: { name: string }) => f.name === 'nexus-latest.json')
62+
if (!latestLink) throw new Error('nexus-latest.json not found')
63+
return (await fetch(latestLink.download_url).then((r) => r.text())).trim()
10164
}
10265

103-
const specUrl = (commit: string) =>
104-
`https://raw.githubusercontent.com/oxidecomputer/omicron/${commit}/openapi/nexus.json`
66+
/** When diffing a single commit, we diff its latest schema against the previous one */
67+
async function getLatestAndPreviousSchema(commit: string) {
68+
const contents = await $`gh api ${SPEC_DIR_URL(commit)}`.json()
10569

106-
async function genForCommit(commit: string, force: boolean) {
107-
const tmpDir = `/tmp/api-diff/${commit}`
108-
const alreadyExists = await exists(tmpDir + '/Api.ts')
70+
const latestLink = contents.find((f: { name: string }) => f.name === 'nexus-latest.json')
71+
if (!latestLink) throw new Error('nexus-latest.json not found')
72+
const latest = (await fetch(latestLink.download_url).then((r) => r.text())).trim()
10973

110-
// if the directory already exists, skip it
111-
if (force || !alreadyExists) {
112-
await $`rm -rf ${tmpDir}`
113-
await $`mkdir -p ${tmpDir}`
114-
console.info(`Generating for ${commit}...`)
115-
await $`npx @oxide/openapi-gen-ts@latest ${specUrl(commit)} ${tmpDir}`
116-
await $`npx prettier --write --log-level error ${tmpDir}`
74+
const schemaFiles = contents
75+
.filter(
76+
(f: { name: string }) => f.name.startsWith('nexus-') && f.name !== 'nexus-latest.json'
77+
)
78+
.map((f: { name: string }) => f.name)
79+
.sort()
80+
81+
const latestIndex = schemaFiles.indexOf(latest)
82+
if (latestIndex === -1) throw new Error(`Latest schema ${latest} not found in dir`)
83+
if (latestIndex === 0) throw new Error('No previous schema version found')
84+
85+
return { previous: schemaFiles[latestIndex - 1], latest }
86+
}
87+
88+
async function resolveTarget(ref1?: string | number, ref2?: string): Promise<DiffTarget> {
89+
// Two refs: compare latest schema on each
90+
if (ref2 !== undefined) {
91+
if (ref1 === undefined)
92+
throw new ValidationError('Provide a base ref when passing two refs')
93+
const [baseCommit, headCommit] = await Promise.all([
94+
resolveCommit(normalizeRef(ref1)),
95+
resolveCommit(normalizeRef(ref2)),
96+
])
97+
const [baseSchema, headSchema] = await Promise.all([
98+
getLatestSchema(baseCommit),
99+
getLatestSchema(headCommit),
100+
])
101+
return { baseCommit, baseSchema, headCommit, headSchema }
117102
}
118103

119-
return tmpDir
104+
// Single ref: compare previous schema to latest within that commit
105+
const commit = await resolveCommit(normalizeRef(ref1))
106+
const { previous, latest } = await getLatestAndPreviousSchema(commit)
107+
return {
108+
baseCommit: commit,
109+
baseSchema: previous,
110+
headCommit: commit,
111+
headSchema: latest,
112+
}
113+
}
114+
115+
async function ensureSchema(commit: string, specFilename: string, force: boolean) {
116+
const dir = `/tmp/api-diff/${commit}/${specFilename}`
117+
const schemaPath = `${dir}/spec.json`
118+
if (force || !(await exists(schemaPath))) {
119+
await $`mkdir -p ${dir}`
120+
console.info(`Downloading ${specFilename}...`)
121+
const content = await fetch(SPEC_RAW_URL(commit, specFilename)).then((r) => r.text())
122+
await Deno.writeTextFile(schemaPath, content)
123+
}
124+
return schemaPath
125+
}
126+
127+
async function ensureClient(schemaPath: string, force: boolean) {
128+
const dir = schemaPath.replace(/\/spec\.json$/, '')
129+
const clientPath = `${dir}/Api.ts`
130+
if (force || !(await exists(clientPath))) {
131+
console.info(`Generating client...`)
132+
await $`npx @oxide/openapi-gen-ts@latest ${schemaPath} ${dir}`
133+
await $`npx prettier --write --log-level error ${dir}`
134+
}
135+
return clientPath
120136
}
121137

122138
//////////////////////////////
@@ -128,20 +144,52 @@ if (!$.commandExistsSync('gh')) throw Error('Need gh (GitHub CLI)')
128144
// prefer delta if it exists. https://dandavison.github.io/delta/
129145
const diffTool = $.commandExistsSync('delta') ? 'delta' : 'diff'
130146

131-
const args = parseArgs(Deno.args, {
132-
alias: { force: 'f', help: 'h' },
133-
boolean: ['force', 'help'],
134-
})
135-
136-
if (args.help) printHelpAndExit()
137-
138-
const { base, head } = await getCommitRange(args._)
147+
await new Command()
148+
.name('api-diff')
149+
.description(
150+
`Display changes to API client or schema caused by a given Omicron PR.
139151
140-
const basePath = (await genForCommit(base, args.force)) + '/Api.ts'
141-
const headPath = (await genForCommit(head, args.force)) + '/Api.ts'
152+
Arguments:
153+
No args Interactive PR picker
154+
<pr> PR number (e.g., 1234)
155+
<commit> Commit SHA
156+
<base> <head> Two refs (commits or PRs), compare latest schema on each
142157
143-
await $`${diffTool} ${basePath} ${headPath} || true`
144-
145-
// useful if you want to open the file directly in an editor
146-
console.info('Before:', basePath)
147-
console.info('After: ', headPath)
158+
Dependencies:
159+
- Deno
160+
- GitHub CLI (gh)
161+
- Optional: delta diff pager https://dandavison.github.io/delta/
162+
- Optional: fzf for PR picker https://github.com/junegunn/fzf`
163+
)
164+
.helpOption('-h, --help', 'Show help')
165+
.option('--force', 'Redo everything even if cached')
166+
.type('format', ({ value }) => {
167+
if (value !== 'ts' && value !== 'schema') {
168+
throw new ValidationError(`Invalid format: '${value}'. Must be 'ts' or 'schema'`)
169+
}
170+
return value
171+
})
172+
.option('-f, --format <format:format>', "Output format: 'ts' or 'schema'", {
173+
default: 'ts' as const,
174+
})
175+
.arguments('[ref1:string] [ref2:string]')
176+
.action(async (options, ref?: string, ref2?: string) => {
177+
const target = await resolveTarget(ref, ref2)
178+
const force = options.force ?? false
179+
180+
const [baseSchema, headSchema] = await Promise.all([
181+
ensureSchema(target.baseCommit, target.baseSchema, force),
182+
ensureSchema(target.headCommit, target.headSchema, force),
183+
])
184+
185+
if (options.format === 'schema') {
186+
await $`${diffTool} ${baseSchema} ${headSchema}`.noThrow()
187+
} else {
188+
const [baseClient, headClient] = await Promise.all([
189+
ensureClient(baseSchema, force),
190+
ensureClient(headSchema, force),
191+
])
192+
await $`${diffTool} ${baseClient} ${headClient}`.noThrow()
193+
}
194+
})
195+
.parse(Deno.args)

0 commit comments

Comments
 (0)