Skip to content

Commit 74f5a34

Browse files
committed
Add metadata to review PRs.
1 parent da6a96e commit 74f5a34

2 files changed

Lines changed: 298 additions & 11 deletions

File tree

scripts/api_md_workflow/create_api_review_pr.js

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const { loadAdapter, loadWorkflowConfig } = require("./adapter_config");
99
const REPO_ROOT = path.resolve(__dirname, "..", "..");
1010
const REMOTE = "origin";
1111
const MAIN_REF = `${REMOTE}/main`;
12+
const SYNC_METADATA_MARKER = "api-md-review-sync";
13+
const SYNC_METADATA_WARNING = "DO NOT MODIFY THESE CONTENTS!";
1214
let logger = console;
1315

1416
function logInfo(message) {
@@ -221,6 +223,14 @@ function packageRelDir(packageDir) {
221223
return path.relative(REPO_ROOT, packageDir).split(path.sep).join("/");
222224
}
223225

226+
function normalizePackageDir(packageDir) {
227+
if (path.isAbsolute(packageDir)) {
228+
return packageRelDir(packageDir);
229+
}
230+
231+
return packageDir.split(path.sep).join("/");
232+
}
233+
224234
function apiMdPath(packageDir) {
225235
return path.join(packageDir, "api.md");
226236
}
@@ -593,6 +603,113 @@ function branchReferenceParts(headSelector) {
593603
};
594604
}
595605

606+
function syncWorkingBranchInfo(headSelector) {
607+
if (!headSelector) {
608+
return null;
609+
}
610+
611+
const targetTag = resolveTargetTag(headSelector);
612+
if (targetTag) {
613+
return null;
614+
}
615+
616+
const { owner, branch } = branchReferenceParts(headSelector);
617+
if (owner === "Azure" && branch === "main") {
618+
return null;
619+
}
620+
621+
return { owner, branch };
622+
}
623+
624+
function buildSyncMetadataObject({ packageName, packageDir, baseBranch, reviewBranch, headSelector }) {
625+
const workingBranch = syncWorkingBranchInfo(headSelector);
626+
if (!workingBranch) {
627+
return null;
628+
}
629+
630+
const metadata = {
631+
schemaVersion: 1,
632+
repository: "Azure/azure-sdk-for-python",
633+
packageName,
634+
packageDir: normalizePackageDir(packageDir),
635+
baseBranch,
636+
reviewBranch,
637+
workingOwner: workingBranch.owner,
638+
workingBranch: workingBranch.branch,
639+
};
640+
641+
const workingPr = findOpenPrForHead(headSelector);
642+
if (workingPr && Number.isInteger(workingPr.number)) {
643+
metadata.workingPrNumber = workingPr.number;
644+
}
645+
646+
return metadata;
647+
}
648+
649+
function buildSyncMetadataBlock(metadata) {
650+
if (!metadata) {
651+
return null;
652+
}
653+
654+
return [
655+
`<!-- ${SYNC_METADATA_MARKER}`,
656+
SYNC_METADATA_WARNING,
657+
JSON.stringify(metadata, null, 2),
658+
"-->",
659+
].join("\n");
660+
}
661+
662+
function replaceSyncMetadataBlock(body, metadataBlock) {
663+
const cleanedBody = String(body || "")
664+
.replace(new RegExp(`<!--\\s*${SYNC_METADATA_MARKER}[\\s\\S]*?-->\\s*`, "g"), "")
665+
.trimEnd();
666+
667+
if (!metadataBlock) {
668+
return cleanedBody;
669+
}
670+
671+
return `${cleanedBody}\n\n${metadataBlock}`;
672+
}
673+
674+
function updatePrBody(prNumber, body) {
675+
return gh(
676+
[
677+
"api",
678+
`repos/Azure/azure-sdk-for-python/pulls/${prNumber}`,
679+
"--method",
680+
"PATCH",
681+
"--field",
682+
`body=${body}`,
683+
],
684+
{ check: false, capture: true },
685+
);
686+
}
687+
688+
function ensurePrBodySyncMetadata(pr, metadataBlock) {
689+
if (!metadataBlock || !pr || !Number.isInteger(pr.number)) {
690+
return;
691+
}
692+
693+
const desiredBody = replaceSyncMetadataBlock(pr.body || "", metadataBlock);
694+
if (desiredBody === (pr.body || "")) {
695+
return;
696+
}
697+
698+
const result = updatePrBody(pr.number, desiredBody);
699+
if (result.status === 0) {
700+
logInfo(`Updated API review sync metadata on PR #${pr.number}.`);
701+
return;
702+
}
703+
704+
const details = [
705+
result.stderr ? `stderr: ${result.stderr.replace(/\r?\n/g, " ").trim()}` : "",
706+
result.stdout ? `stdout: ${result.stdout.replace(/\r?\n/g, " ").trim()}` : "",
707+
]
708+
.filter(Boolean)
709+
.join("\n ");
710+
logWarning(`WARNING: failed to update API review sync metadata on PR #${pr.number}.` + (details ? `\n ${details}` : ""));
711+
}
712+
596713
function findOpenPrForHead(headSelector) {
597714
const { owner, branch } = branchReferenceParts(headSelector);
598715
const selector = `${owner}:${branch}`;
@@ -684,7 +801,7 @@ function findOpenPrForBranches(baseBranch, headBranch) {
684801
"--state",
685802
"open",
686803
"--json",
687-
"number,url,state,updatedAt",
804+
"number,url,state,updatedAt,body",
688805
"--limit",
689806
"20",
690807
],
@@ -707,7 +824,7 @@ function findOpenPrForBranches(baseBranch, headBranch) {
707824
"--search",
708825
`repo:Azure/azure-sdk-for-python is:pr is:open head:${headBranch} base:${baseBranch}`,
709826
"--json",
710-
"number,url,state,updatedAt",
827+
"number,url,state,updatedAt,body",
711828
"--limit",
712829
"20",
713830
],
@@ -979,19 +1096,31 @@ async function main() {
9791096
const workingSelector = args.target || "main";
9801097
const workingReference = targetReferenceInfo(workingSelector);
9811098
const baselineRef = baselineReferenceMarkdown(args.base);
982-
983-
const body = [
984-
`Automated API review PR for ${args.packageName}.`,
985-
"",
986-
`- **${workingReference.label}:** ${workingReference.markdown} (version ${targetVersion})`,
987-
`- **Baseline:** ${baselineRef} (version ${baseVersion})`,
988-
"",
989-
"Generated by scripts/api_md_workflow/create_api_review_pr.js.",
990-
].join("\n");
1099+
const syncMetadata = buildSyncMetadataObject({
1100+
packageName: args.packageName,
1101+
packageDir,
1102+
baseBranch,
1103+
reviewBranch,
1104+
headSelector: args.target,
1105+
});
1106+
const syncMetadataBlock = buildSyncMetadataBlock(syncMetadata);
1107+
1108+
const body = replaceSyncMetadataBlock(
1109+
[
1110+
`Automated API review PR for ${args.packageName}.`,
1111+
"",
1112+
`- **${workingReference.label}:** ${workingReference.markdown} (version ${targetVersion})`,
1113+
`- **Baseline:** ${baselineRef} (version ${baseVersion})`,
1114+
"",
1115+
"Generated by scripts/api_md_workflow/create_api_review_pr.js.",
1116+
].join("\n"),
1117+
syncMetadataBlock,
1118+
);
9911119

9921120
if (baseSelection.reused && reviewSelection.reused) {
9931121
const existingPr = findOpenPrForBranches(baseBranch, reviewBranch);
9941122
if (existingPr) {
1123+
ensurePrBodySyncMetadata(existingPr, syncMetadataBlock);
9951124
logInfo(`\n=== Reusing existing PR #${existingPr.number} ===`);
9961125
logInfo(existingPr.url);
9971126
return 0;
@@ -1009,6 +1138,7 @@ async function main() {
10091138
} else {
10101139
const existingPr = findOpenPrForBranches(baseBranch, reviewBranch);
10111140
if (existingPr) {
1141+
ensurePrBodySyncMetadata(existingPr, syncMetadataBlock);
10121142
logInfo(`\n=== Reusing existing PR #${existingPr.number} ===`);
10131143
logInfo(existingPr.url);
10141144
return 0;
@@ -1057,6 +1187,9 @@ if (require.main === module) {
10571187
gh = ghRunner;
10581188
}
10591189
},
1190+
buildSyncMetadataBlock,
1191+
buildSyncMetadataObject,
1192+
replaceSyncMetadataBlock,
10601193
targetReferenceInfo,
10611194
};
10621195
}

scripts/api_md_workflow/create_api_review_pr.test.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ function stubGhWithSearchResults(results) {
2525
};
2626
}
2727

28+
function parseSyncMetadataBlock(block) {
29+
const jsonText = block
30+
.replace(/^<!-- api-md-review-sync\n/, "")
31+
.replace(/^DO NOT MODIFY THESE CONTENTS!\n/, "")
32+
.replace(/\n-->$/, "");
33+
return JSON.parse(jsonText);
34+
}
35+
2836
test("targetReferenceInfo links matching open PR from direct head query", () => {
2937
workflow.__setCommandRunners({
3038
git: stubGitNoTags,
@@ -143,4 +151,150 @@ test("targetReferenceInfo treats existing target tag as tag and does not query P
143151
markdown: "[tag `azure-example_1.2.3`](https://github.com/Azure/azure-sdk-for-python/commit/abc123def456)",
144152
});
145153
assert.equal(prLookupCount, 0);
154+
});
155+
156+
test("buildSyncMetadataObject creates hidden metadata for origin branch target", () => {
157+
workflow.__setCommandRunners({
158+
git: stubGitNoTags,
159+
gh: (args) => {
160+
if (args.includes("--head")) {
161+
return commandResult(
162+
JSON.stringify([
163+
{
164+
number: 47203,
165+
url: "https://github.com/Azure/azure-sdk-for-python/pull/47203",
166+
state: "OPEN",
167+
updatedAt: "2026-06-05T00:00:00Z",
168+
headRefName: "feature/api-change",
169+
headRepositoryOwner: { login: "Azure" },
170+
},
171+
]),
172+
);
173+
}
174+
175+
return commandResult("[]");
176+
},
177+
});
178+
179+
const metadata = workflow.buildSyncMetadataObject({
180+
packageName: "azure-example",
181+
packageDir: "sdk/service/azure-example",
182+
baseBranch: "apireview/base_azure-example_1.0.0",
183+
reviewBranch: "apireview/review_azure-example_1.1.0",
184+
headSelector: "feature/api-change",
185+
});
186+
const block = workflow.buildSyncMetadataBlock(metadata);
187+
188+
assert.ok(block.startsWith("<!-- api-md-review-sync\nDO NOT MODIFY THESE CONTENTS!\n"));
189+
assert.ok(block.endsWith("\n-->"));
190+
assert.deepEqual(parseSyncMetadataBlock(block), {
191+
schemaVersion: 1,
192+
repository: "Azure/azure-sdk-for-python",
193+
packageName: "azure-example",
194+
packageDir: "sdk/service/azure-example",
195+
baseBranch: "apireview/base_azure-example_1.0.0",
196+
reviewBranch: "apireview/review_azure-example_1.1.0",
197+
workingOwner: "Azure",
198+
workingBranch: "feature/api-change",
199+
workingPrNumber: 47203,
200+
});
201+
});
202+
203+
test("buildSyncMetadataObject records fork owner and branch target", () => {
204+
workflow.__setCommandRunners({
205+
git: stubGitNoTags,
206+
gh: stubGhWithSearchResults([
207+
{
208+
number: 47204,
209+
url: "https://github.com/Azure/azure-sdk-for-python/pull/47204",
210+
state: "OPEN",
211+
updatedAt: "2026-06-05T00:00:00Z",
212+
headRefName: "users/example/feature",
213+
headRepositoryOwner: { login: "example" },
214+
},
215+
]),
216+
});
217+
218+
const metadata = workflow.buildSyncMetadataObject({
219+
packageName: "azure-example",
220+
packageDir: "sdk/service/azure-example",
221+
baseBranch: "apireview/base_azure-example_1.0.0",
222+
reviewBranch: "apireview/review_azure-example_1.1.0",
223+
headSelector: "example:users/example/feature",
224+
});
225+
226+
assert.equal(metadata.workingOwner, "example");
227+
assert.equal(metadata.workingBranch, "users/example/feature");
228+
assert.equal(metadata.workingPrNumber, 47204);
229+
});
230+
231+
test("buildSyncMetadataObject omits metadata for tag and default main targets", () => {
232+
let prLookupCount = 0;
233+
234+
workflow.__setCommandRunners({
235+
git: (args) => {
236+
if (args[0] === "rev-parse" && args.includes("refs/tags/azure-example_1.2.3")) {
237+
return commandResult("", 0);
238+
}
239+
240+
return commandResult("", 1);
241+
},
242+
gh: () => {
243+
prLookupCount += 1;
244+
return commandResult("[]");
245+
},
246+
});
247+
248+
assert.equal(
249+
workflow.buildSyncMetadataObject({
250+
packageName: "azure-example",
251+
packageDir: "sdk/service/azure-example",
252+
baseBranch: "apireview/base_azure-example_1.0.0",
253+
reviewBranch: "apireview/review_azure-example_1.1.0",
254+
headSelector: "azure-example_1.2.3",
255+
}),
256+
null,
257+
);
258+
assert.equal(
259+
workflow.buildSyncMetadataObject({
260+
packageName: "azure-example",
261+
packageDir: "sdk/service/azure-example",
262+
baseBranch: "apireview/base_azure-example_1.0.0",
263+
reviewBranch: "apireview/review_azure-example_1.1.0",
264+
headSelector: null,
265+
}),
266+
null,
267+
);
268+
assert.equal(prLookupCount, 0);
269+
});
270+
271+
test("replaceSyncMetadataBlock replaces stale hidden metadata", () => {
272+
const oldBlock = workflow.buildSyncMetadataBlock({
273+
schemaVersion: 1,
274+
repository: "Azure/azure-sdk-for-python",
275+
packageName: "old-package",
276+
packageDir: "sdk/service/old-package",
277+
baseBranch: "apireview/base_old-package_1.0.0",
278+
reviewBranch: "apireview/review_old-package_1.1.0",
279+
workingOwner: "Azure",
280+
workingBranch: "old-feature",
281+
});
282+
const newBlock = workflow.buildSyncMetadataBlock({
283+
schemaVersion: 1,
284+
repository: "Azure/azure-sdk-for-python",
285+
packageName: "azure-example",
286+
packageDir: "sdk/service/azure-example",
287+
baseBranch: "apireview/base_azure-example_1.0.0",
288+
reviewBranch: "apireview/review_azure-example_1.1.0",
289+
workingOwner: "Azure",
290+
workingBranch: "feature/api-change",
291+
});
292+
293+
const body = workflow.replaceSyncMetadataBlock(`Review body\n\n${oldBlock}`, newBlock);
294+
295+
assert.ok(body.startsWith("Review body\n\n<!-- api-md-review-sync"));
296+
assert.ok(body.includes("DO NOT MODIFY THESE CONTENTS!"));
297+
assert.ok(body.includes('"packageName": "azure-example"'));
298+
assert.equal(body.includes("old-package"), false);
299+
assert.equal((body.match(/api-md-review-sync/g) || []).length, 1);
146300
});

0 commit comments

Comments
 (0)