88 * Copyright Oxide Computer Company
99 */
1010import { 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'
1211import { $ } 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 ( / \/ s p e c \. j s o n $ / , '' )
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/
129145const 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