Skip to content

Commit b5aae50

Browse files
tjprescottCopilotmikeharder
authored
Python POC for GitHub-based API Reviews (Phase 1) (#47203)
* Add generate_api_text.py script. * Add PR creation script. * Review feedback. * Add scripts for syncing api.md * Refactor create_api_review_pr from Python to JS in a module way. * Remove update process and just have a consistency check. * Add minor change to azure-template to test pipeline. * Add API.md to test mismatch. * Apply actual api.md * Update GitHub Actions to latest major versions Co-authored-by: mikeharder <9459391+mikeharder@users.noreply.github.com> * Add shared JS scripts from rest-api-specs repo. * Refactor scripts to use shared code and simplify. * Code review feedback. * Add generated API.md for azure-template Co-authored-by: tjprescott <5723682+tjprescott@users.noreply.github.com> * Refactor to use `azpysdk apistub --md` command. * Remove Python 3.10 limit on apistub.py. * Refactor `azpysdk apistub` command. Extract metadata from API.md. * Add validation for select metadata fields. * CI fixes. * Add skill for generating review PR. * Update review generation logic. * fix(python adapter): add shell:true on Windows for spawnSync * fix(python adapter): pass --dest-dir so API.md lands in package dir * fix(python adapter): skip _generated dirs in readVersion to avoid stale versions * Make script idempotent and reuse branches with the same API hash. * Code review feedback. * Updates. * Make review PR creation only work for updates, not new packages. * CI fixes. * Use shared helper method. * Add metadata to review PRs. * Code review feedback. * Add package-lock.json * Refactor to use simple-git and octokit * Code review feedback. * Remove template APIs. * Code review feedback. * Refactor review create script to Python. * Test consistency refactor. * Improve failure logging. Fix CI * Test resovling consistency * Script fixes. * Tweaks and remove APIs (more testing) * Code review feedback. * Consolidate package.json. * Code review feedback * restore chronus * remove .github/package.json * remove unnecessary shared src * add readme with "copied from" * Fix CI --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mikeharder <9459391+mikeharder@users.noreply.github.com> Co-authored-by: tjprescott <5723682+tjprescott@users.noreply.github.com> Co-authored-by: Mike Harder <mharder@microsoft.com>
1 parent 60cd541 commit b5aae50

27 files changed

Lines changed: 2846 additions & 48 deletions

.github/shared/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# `.github/shared`
2+
3+
Copied from https://github.com/Azure/azure-rest-api-specs/tree/main/.github/shared

.github/shared/src/cache.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Caches values in memory with a single key of any type.
3+
*
4+
* @template K, V
5+
*/
6+
export class KeyedCache {
7+
/** @type {Map<K, V>} */
8+
#map = new Map();
9+
10+
/**
11+
* Returns cached value, initializing if necessary
12+
*
13+
* @param {K} key
14+
* @param {() => V} factory
15+
* @returns {V} cached value
16+
*
17+
* @example
18+
* const result = cache.getOrCreate(42, async () => await doWork(42));
19+
*/
20+
getOrCreate(key, factory) {
21+
let value = this.#map.get(key);
22+
23+
if (value === undefined) {
24+
value = factory();
25+
this.#map.set(key, value);
26+
}
27+
28+
return value;
29+
}
30+
}
31+
32+
/**
33+
* Caches values in memory with an ordered pair of keys of any types.
34+
*
35+
* @template K1, K2, V
36+
*/
37+
export class KeyedPairCache {
38+
// Two-layer nested cache
39+
/** @type {KeyedCache<K1, KeyedCache<K2, V>>} */
40+
#cache1 = new KeyedCache();
41+
42+
/**
43+
* Returns cached value, initializing if necessary.
44+
* Keys are ordered, so (key1, key2) != (key2, key1).
45+
*
46+
* @param {K1} key1
47+
* @param {K2} key2
48+
* @param {() => V} factory
49+
* @returns {V} cached value
50+
*
51+
* @example
52+
* const result = cache.getOrCreate(42, 7, async () => await doWork(42, 7));
53+
*/
54+
getOrCreate(key1, key2, factory) {
55+
// key1 => cache for the next layer
56+
const cache2 = this.#cache1.getOrCreate(key1, () => new KeyedCache());
57+
58+
// key2 => final value
59+
return cache2.getOrCreate(key2, factory);
60+
}
61+
}

.github/shared/src/exec.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import child_process from "child_process";
2+
import { dirname, join } from "path";
3+
import { promisify } from "util";
4+
const execFileImpl = promisify(child_process.execFile);
5+
6+
/**
7+
* @typedef {Object} ExecOptions
8+
* @property {string} [cwd] Current working directory. Default: process.cwd().
9+
* @property {import('./logger.js').ILogger} [logger]
10+
* @property {boolean} [logOutput] Log captured stdout/stderr at info level. Default: false.
11+
* @property {number} [maxBuffer] Max bytes allowed on stdout or stderr. Default: 16 * 1024 * 1024.
12+
*/
13+
14+
/**
15+
* @typedef {Object} NpmPrefixOptions
16+
* @property {string} [prefix] Prefix to pass to npm via "--prefix".
17+
*/
18+
19+
/**
20+
* @typedef {ExecOptions & NpmPrefixOptions} ExecNpmOptions
21+
*/
22+
23+
/**
24+
* @typedef {Object} ExecResult
25+
* @property {string} stdout
26+
* @property {string} stderr
27+
*/
28+
29+
/**
30+
* @typedef {Error & { stdout?: string, stderr?: string, code?: number }} ExecError
31+
*/
32+
33+
/**
34+
* Checks whether an unknown error object is an ExecError.
35+
* @param {unknown} error
36+
* @returns {error is ExecError}
37+
*/
38+
export function isExecError(error) {
39+
if (!(error instanceof Error)) return false;
40+
41+
const e = /** @type {ExecError} */ (error);
42+
return typeof e.stdout === "string" || typeof e.stderr === "string";
43+
}
44+
45+
/**
46+
* Wraps `child_process.execFile()`, adding logging and a larger default maxBuffer.
47+
*
48+
* @param {string} file
49+
* @param {string[]} [args]
50+
* @param {ExecOptions} [options]
51+
* @returns {Promise<ExecResult>}
52+
* @throws {ExecError}
53+
*/
54+
export async function execFile(file, args, options = {}) {
55+
const {
56+
cwd,
57+
logger,
58+
logOutput = false,
59+
// Node default is 1024 * 1024, which is too small for some git commands returning many entities or large file content.
60+
// To support "git show", should be larger than the largest swagger file in the repo (2.5 MB as of 2/28/2025).
61+
maxBuffer = 16 * 1024 * 1024,
62+
} = options;
63+
64+
logger?.info(`execFile("${file}", ${JSON.stringify(args)})`);
65+
66+
try {
67+
// execFile(file, args) is more secure than exec(cmd), since the latter is vulnerable to shell injection
68+
const result = await execFileImpl(file, args, {
69+
cwd,
70+
maxBuffer,
71+
});
72+
73+
logger?.debug(`stdout: '${result.stdout}'`);
74+
logger?.debug(`stderr: '${result.stderr}'`);
75+
if (logOutput) {
76+
if (result.stdout) {
77+
logger?.info(result.stdout.trimEnd());
78+
}
79+
if (result.stderr) {
80+
logger?.info(result.stderr.trimEnd());
81+
}
82+
}
83+
84+
return result;
85+
} catch (error) {
86+
/* v8 ignore next */
87+
logger?.debug(`error: '${JSON.stringify(error)}'`);
88+
if (logOutput && isExecError(error)) {
89+
if (error.stdout) {
90+
logger?.info(error.stdout.trimEnd());
91+
}
92+
if (error.stderr) {
93+
logger?.info(error.stderr.trimEnd());
94+
}
95+
}
96+
97+
throw error;
98+
}
99+
}
100+
101+
/**
102+
* Calls `execFile()` with appropriate arguments to run `npm` on all platforms
103+
*
104+
* @param {string[]} args
105+
* @param {ExecNpmOptions} [options]
106+
* @returns {Promise<ExecResult>}
107+
* @throws {ExecError}
108+
*/
109+
export async function execNpm(args, options = {}) {
110+
const { prefix } = options;
111+
112+
// Exclude platform-specific code from coverage
113+
/* v8 ignore start */
114+
const { file, defaultArgs } =
115+
process.platform === "win32"
116+
? {
117+
// Only way I could find to run "npm" on Windows, without using the shell (e.g. "cmd /c npm ...")
118+
//
119+
// "node.exe", ["--", "npm-cli.js", ...args]
120+
//
121+
// The "--" MUST come BEFORE "npm-cli.js", to ensure args are sent to the script unchanged.
122+
// If the "--" comes after "npm-cli.js", the args sent to the script will be ["--", ...args],
123+
// which is NOT equivalent, and can break if args itself contains another "--".
124+
125+
// example: "C:\Program Files\nodejs\node.exe"
126+
file: process.execPath,
127+
128+
// example: "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js"
129+
defaultArgs: [
130+
"--",
131+
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
132+
],
133+
}
134+
: { file: "npm", defaultArgs: [] };
135+
/* v8 ignore stop */
136+
137+
const prefixArgs = prefix ? ["--prefix", prefix] : [];
138+
139+
return await execFile(file, [...defaultArgs, ...prefixArgs, ...args], options);
140+
}
141+
142+
/**
143+
* Calls `execNpm()` with arguments ["exec", "--no", "--"] prepended.
144+
*
145+
* @param {string[]} args
146+
* @param {ExecNpmOptions} [options]
147+
* @returns {Promise<ExecResult>}
148+
* @throws {ExecError}
149+
*/
150+
export async function execNpmExec(args, options = {}) {
151+
return await execNpm(["exec", "--no", "--", ...args], options);
152+
}

.github/shared/src/logger.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @typedef {Object} ILogger
3+
* @property {(message:string) => void} debug
4+
* @property {(message:string) => void} error
5+
* @property {(message:string) => void} info
6+
* @property {(message:string) => void} warning
7+
* @property {() => boolean} isDebug
8+
*/
9+
10+
/**
11+
* @implements {ILogger}
12+
*/
13+
export class ConsoleLogger {
14+
/** @type {boolean} */
15+
#isDebug;
16+
17+
/**
18+
* @param {boolean} [isDebug] - If true, debug logs will be printed. Default: false.
19+
*/
20+
constructor(isDebug = false) {
21+
this.#isDebug = isDebug;
22+
}
23+
24+
/**
25+
* @param {string} message
26+
*/
27+
debug(message) {
28+
if (this.isDebug()) {
29+
console.debug(message);
30+
}
31+
}
32+
33+
/**
34+
* @param {string} message
35+
*/
36+
error(message) {
37+
console.error(message);
38+
}
39+
40+
/**
41+
* @param {string} message
42+
*/
43+
info(message) {
44+
console.log(message);
45+
}
46+
47+
/**
48+
* @returns {boolean}
49+
*/
50+
isDebug() {
51+
return this.#isDebug;
52+
}
53+
54+
/**
55+
* @param {string} message
56+
*/
57+
warning(message) {
58+
console.warn(message);
59+
}
60+
}
61+
62+
// Singleton loggers
63+
export const defaultLogger = new ConsoleLogger();
64+
export const debugLogger = new ConsoleLogger(/*isDebug*/ true);

.github/shared/src/path.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { basename, dirname, resolve } from "path";
2+
3+
import { KeyedCache, KeyedPairCache } from "./cache.js";
4+
5+
/** @type {KeyedCache<string, string>} */
6+
const resolveCache = new KeyedCache();
7+
8+
/** @type {KeyedPairCache<string, string, string>} */
9+
const resolvePairCache = new KeyedPairCache();
10+
11+
/**
12+
*
13+
* @param {string} path Absolute or relative path
14+
* @param {string} segment File or folder
15+
* @returns {boolean} True if resolved path contains segment
16+
*
17+
* @example
18+
* includesSegment("stable/2025-01-01/examples/foo.json", "examples")
19+
* // -> true
20+
*/
21+
export function includesSegment(path, segment) {
22+
return untilLastSegment(path, segment) !== "";
23+
}
24+
25+
/**
26+
* Wraps `path.resolve(path)` with a cache to improve performance
27+
*
28+
* @param {string} path
29+
* @returns {string}
30+
*/
31+
export function resolveCached(path) {
32+
return resolveCache.getOrCreate(path, () => resolve(path));
33+
}
34+
35+
/**
36+
* Wraps `path.resolve(from, to)` with a cache to improve performance
37+
38+
* @param {string} from
39+
* @param {string} to
40+
* @returns {string}
41+
*/
42+
export function resolvePairCached(from, to) {
43+
return resolvePairCache.getOrCreate(from, to, () => resolve(from, to));
44+
}
45+
46+
/**
47+
* @param {string} path Absolute or relative path
48+
* @param {string} segment File or folder
49+
* @returns {string} Portion of resolved path up to (and including) the last occurrence of segment
50+
*
51+
* @example
52+
* untilLastSegment("stable/2025-01-01/examples/foo.json", "examples")
53+
* // -> "{cwd}/stable/2025-01-01/examples"
54+
*/
55+
export function untilLastSegment(path, segment) {
56+
// Shares code with `untilLastSegmentWithParent()`, but not worth refactoring yet
57+
58+
let current = resolveCached(path);
59+
60+
while (true) {
61+
const parent = dirname(current);
62+
63+
if (basename(current) === segment) {
64+
// Found the target folder. Return it.
65+
return current;
66+
} else if (parent === current) {
67+
// Reached the filesystem root (folder not found). Return empty string.
68+
return "";
69+
} else {
70+
// Keep walking upward
71+
current = parent;
72+
}
73+
}
74+
}
75+
76+
/**
77+
* @param {string} path Absolute or relative path
78+
* @param {string} segment File or folder
79+
* @returns {string} Portion of resolved path up to (and including) the last segment with the specified parent
80+
*
81+
* @example
82+
* untilLastSegmentWithParent("specification/foo/data-plane/stable/2025-01-01/foo.json", "specification")
83+
* // -> "{cwd}/specification/foo"
84+
*/
85+
export function untilLastSegmentWithParent(path, segment) {
86+
// Shares code with `untilLastSegment()`, but not worth refactoring yet
87+
88+
let current = resolveCached(path);
89+
90+
while (true) {
91+
const parent = dirname(current);
92+
93+
if (basename(parent) === segment) {
94+
// Found the target parent. Return current;
95+
return current;
96+
} else if (parent === current) {
97+
// Reached the filesystem root (folder not found). Return empty string.
98+
return "";
99+
} else {
100+
// Keep walking upward
101+
current = parent;
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)