From 33460a8d823bebb499e85be8448cc2b22c78c59c Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Wed, 9 Jul 2025 22:21:35 +0000 Subject: [PATCH 01/67] add unit tests for the existing logic. time to update the logic to one where we process the label set, versus an individual labelled event --- .../workflows/test/summarize-checks.test.js | 132 +++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index dee9ce4f8379..132459ace4e1 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -2,13 +2,15 @@ import { describe, expect, it } from "vitest"; import { createNextStepsComment, summarizeChecksImpl, + handleLabeledEvent } from "../src/summarize-checks/summarize-checks.js"; -import { createMockCore } from "./mocks.js"; +import { createMockCore, createMockGithub } from "./mocks.js"; import { Octokit } from "@octokit/rest"; const mockCore = createMockCore(); +const mockGithub = createMockGithub(); -describe("summarizeChecksImpl", () => { +describe("Summarize Checks Tests", () => { describe("next steps comment rendering", () => { it("Should generate summary for a mockdata PR scenario", async () => { const repo = "azure-rest-api-specs"; @@ -511,4 +513,130 @@ describe("summarizeChecksImpl", () => { 600000, ); }); + + describe("label add and remove", () => { + const testCases = [ + { + description: "labeled: ARMChangesRequested with WaitForARMFeedback present", + eventName: "labeled", + changedLabel: "ARMChangesRequested", + existingLabels: ["WaitForARMFeedback", "other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: ["WaitForARMFeedback"] + }, + { + description: "labeled: ARMChangesRequested without WaitForARMFeedback", + eventName: "labeled", + changedLabel: "ARMChangesRequested", + existingLabels: ["other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: [] + }, + { + description: "labeled: ARMSignedOff with both labels present", + eventName: "labeled", + changedLabel: "ARMSignedOff", + existingLabels: ["WaitForARMFeedback", "ARMChangesRequested", "other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: ["WaitForARMFeedback", "ARMChangesRequested"] + }, + { + description: "labeled: ARMSignedOff with only WaitForARMFeedback present", + eventName: "labeled", + changedLabel: "ARMSignedOff", + existingLabels: ["WaitForARMFeedback", "other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: ["WaitForARMFeedback"] + }, + { + description: "labeled: ARMSignedOff with only ARMChangesRequested present", + eventName: "labeled", + changedLabel: "ARMSignedOff", + existingLabels: ["ARMChangesRequested", "other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: ["ARMChangesRequested"] + }, + { + description: "labeled: ARMSignedOff with neither label present", + eventName: "labeled", + changedLabel: "ARMSignedOff", + existingLabels: ["other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: [] + }, + { + description: "unlabeled: ARMChangesRequested with WaitForARMFeedback present (buggy case)", + eventName: "unlabeled", + changedLabel: "ARMChangesRequested", + existingLabels: ["WaitForARMFeedback", "other-label"], + expectedLabelsToAdd: ["WaitForARMFeedback"], + expectedLabelsToRemove: [] + }, + { + description: "unlabeled: ARMChangesRequested without WaitForARMFeedback", + eventName: "unlabeled", + changedLabel: "ARMChangesRequested", + existingLabels: ["other-label"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: [] + }, + { + description: "labeled: other label should have no effect", + eventName: "labeled", + changedLabel: "SomeOtherLabel", + existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: [] + }, + { + description: "unlabeled: other label should have no effect", + eventName: "unlabeled", + changedLabel: "SomeOtherLabel", + existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], + expectedLabelsToAdd: [], + expectedLabelsToRemove: [] + } + ]; + + it.each(testCases)( + "$description", + async ({ eventName, changedLabel, existingLabels, expectedLabelsToAdd, expectedLabelsToRemove }) => { + const repo = "azure-rest-api-specs"; + const owner = "Azure"; + const mockContext = { + repo: { + owner: owner, + repo: repo, + }, + payload: { + action: eventName, + pull_request: { + number: 1, + head: { + sha: "abc123", + }, + }, + label: { + name: changedLabel + } + }, + eventName: eventName, + }; + + const [labelsToAdd, labelsToRemove] = await handleLabeledEvent( + mockGithub, + mockContext, + mockCore, + owner, + repo, + 1, + eventName, + existingLabels, + ); + + expect(labelsToAdd).toEqual(expectedLabelsToAdd); + expect(labelsToRemove).toEqual(expectedLabelsToRemove); + } + ); + }); }); From b8d396dc32ba1f2d10d23b66ae4c5901238c159c Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 10 Jul 2025 00:39:40 +0000 Subject: [PATCH 02/67] update the test. we're fe failing now! --- .../summarize-checks/investigate-labelling.md | 46 +++++ .../src/summarize-checks/summarize-checks.js | 178 +++++++++--------- .../workflows/test/summarize-checks.test.js | 18 +- 3 files changed, 147 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/src/summarize-checks/investigate-labelling.md diff --git a/.github/workflows/src/summarize-checks/investigate-labelling.md b/.github/workflows/src/summarize-checks/investigate-labelling.md new file mode 100644 index 000000000000..37ad7e0d6a5d --- /dev/null +++ b/.github/workflows/src/summarize-checks/investigate-labelling.md @@ -0,0 +1,46 @@ +# Needing "label" context when handling the listed labels + + +This is the original logic +```js + if (event_name === "labeled") { + if (changedLabel == "ARMChangesRequested") { + if (known_labels.indexOf("WaitForARMFeedback") !== -1) { + labelsToRemove.add("WaitForARMFeedback"); + } + } + if (changedLabel == "ARMSignedOff") { + if (known_labels.indexOf("WaitForARMFeedback") !== -1) { + labelsToRemove.add("WaitForARMFeedback"); + } + if (known_labels.indexOf("ARMChangesRequested") !== -1) { + labelsToRemove.add("ARMChangesRequested"); + } + } + } else if (event_name === "unlabeled") { + if (changedLabel == "ARMChangesRequested") { + if (known_labels.indexOf("WaitForARMFeedback") !== -1) { + labelsToAdd.add("WaitForARMFeedback"); + } + } + } +``` + +So: + +``` +fn(event, changedLabel, knownLabels) + +fn("labeled", "ArmChangesRequested", ["ARMChangesRequested", "WaitForARMFeedback"]) + -> labelsToAdd: [], labelsToRemove: ["WaitForarmFeedback"] + +fn("unlabeled", "ARMChangesRequested", ["WaitForARMFeedback"]) + -> labelsToAdd: ["WaitForArmFeedback"], labelsToRemove: [] +``` + +Can we replace this with straightforward results? + + +input ["ARMChangesRequested", "WaitForARMFeedback"] -> Remove WaitForArmFeedback +input [No ArmChangesRequested] -> Add WaitForArmFeedback + diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 2b85c3d049d9..7b0f4d5be758 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -264,28 +264,43 @@ export async function summarizeChecksImpl( /** @type {string[]} */ let labelNames = labels.map((/** @type {{ name: string; }} */ label) => label.name); - // handle our label trigger first, we may bail out early if it's a label action we're reacting to - // this also implies that if a label action is performed before any workflows complete, we shouldn't - // accidentally update the next steps to merge with the results of the workflows that haven't completed yet. - if (event_name in ["labeled", "unlabeled"]) { - // if anything goes wrong with label actions, the invocation will end within handleLabeledEvent due to localized error handling - const [labelsToAdd, labelsToRemove] = await handleLabeledEvent( - github, - context, - core, - owner, - repo, - issue_number, - event_name, - labelNames, + /** @type { string | undefined } */ + const changedLabel = context.payload.label?.name; + + const [labelsToAdd, labelsToRemove] = await updateLabels( + event_name, + targetBranch, + labelNames, + changedLabel + ); + + for (const label of labelsToRemove) { + core.info(`Removing label: ${label} from ${owner}/${repo}#${issue_number}.`); + // await github.rest.issues.removeLabel({ + // owner: owner, + // repo: repo, + // issue_number: issue_number, + // name: label, + // }); + } + + if (labelsToAdd.length > 0) { + core.info( + `Adding labels: ${Array.from(labelsToAdd).join(", ")} to ${owner}/${repo}#${issue_number}.`, ); + // await github.rest.issues.addLabels({ + // owner: owner, + // repo: repo, + // issue_number: issue_number, + // labels: Array.from(labelsToAdd), + // }); + } - // adjust labelNames based on labelsToAdd/labelsToRemove - labelNames = labelNames.filter((name) => !labelsToRemove.includes(name)); - for (const label of labelsToAdd) { - if (!labelNames.includes(label)) { - labelNames.push(label); - } + // adjust labelNames based on labelsToAdd/labelsToRemove + labelNames = labelNames.filter((name) => !labelsToRemove.includes(name)); + for (const label of labelsToAdd) { + if (!labelNames.includes(label)) { + labelNames.push(label); } } @@ -402,82 +417,77 @@ function getGraphQLQuery(owner, repo, sha, prNumber) { // #endregion // #region label update + /** - * @param {(import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api & { paginate: import("@octokit/plugin-paginate-rest").PaginateInterface; })} github - * @param {import('@actions/github').context } context - * @param {typeof import("@actions/core")} core - * @param {string} owner - * @param {string} repo - * @param {number} issue_number - * @param {string} event_name - * @param {string[]} known_labels + * + * @param {any[]} arr + * @param {any[]} values + * @returns + */ +function containsAll(arr, values) { + return values.every((value) => arr.includes(value)); +} + +/** + * + * @param {any[]} arr + * @param {any[]} values + * @returns + */ +function containsNone(arr, values) { + return values.every((value) => !arr.includes(value)); +} + +/** + * + * @param {any[]} arr + * @param {any[]} values + * @returns + */ +function containsAny(arr, values) { + return values.some((value) => arr.includes(value)); +} + +/** + * @param {string} eventName + * @param {string} targetBranch + * @param {string[]} existingLabels + * @param {string | undefined } changedLabel * @returns {Promise<[string[], string[]]>} */ -// @ts-ignore: 'github' is currently unused but will be used after necessary changes -export async function handleLabeledEvent( - github, - context, - core, - owner, - repo, - issue_number, - event_name, - known_labels, +export async function updateLabels( + eventName, + targetBranch, + existingLabels, + changedLabel ) { - // logic for this event is based on code directly ripped from pipelinebot: - // private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts - // todo: further enhance with labelling actions from `PR Summary` check. - const changedLabel = context.payload.label?.name; + // logic for this function originally present in: + // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts + // - public/rest-api-specs-scripts/src/prSummary.ts + // it has since been simplified and moved here to handle all label addition and subtraction given a PR context + const labelsToAdd = new Set(); const labelsToRemove = new Set(); + const sortedLabels = existingLabels.sort((a, b) => a.localeCompare(b)); - if (event_name === "labeled") { - if (changedLabel == "ARMChangesRequested") { - if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - labelsToRemove.add("WaitForARMFeedback"); - } - } - if (changedLabel == "ARMSignedOff") { - if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - labelsToRemove.add("WaitForARMFeedback"); - } - if (known_labels.indexOf("ARMChangesRequested") !== -1) { - labelsToRemove.add("ARMChangesRequested"); - } - } - - for (const label of labelsToRemove) { - core.info(`Removing label: ${label} from ${owner}/${repo}#${issue_number}.`); - // await github.rest.issues.removeLabel({ - // owner: owner, - // repo: repo, - // issue_number: issue_number, - // name: label, - // }); - } - } else if (event_name === "unlabeled") { - if (changedLabel == "ARMChangesRequested") { - if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - labelsToAdd.add("WaitForARMFeedback"); - } - } - - if (labelsToAdd.size > 0) { - core.info( - `Adding labels: ${Array.from(labelsToAdd).join(", ")} to ${owner}/${repo}#${issue_number}.`, - ); - // await github.rest.issues.addLabels({ - // owner: owner, - // repo: repo, - // issue_number: issue_number, - // labels: Array.from(labelsToAdd), - // }); - } + // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels + if (containsAll(sortedLabels, ["ARMSignedOff"])) { + labelsToRemove.add("ARMChangesRequested"); + labelsToRemove.add("WaitForARMFeedback"); + } + // if we are waiting for ARM feedback, we should add the "WaitForARMFeedback" label + else if (containsAll(sortedLabels, ["ARMChangesRequested"]) && containsNone(sortedLabels, ["ARMSignedOff"])) { + labelsToRemove.add("WaitForARMFeedback"); + } + // finally, if ARMChangesRequested are not present, and we've gotten here by lack of signoff, we should add the "WaitForARMFeedback" label + else if (containsNone(sortedLabels, ["ARMChangesRequested"])) { + labelsToAdd.add("WaitForARMFeedback"); } return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; } + // #endregion // #region checks /** diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 132459ace4e1..5c075750a515 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createNextStepsComment, summarizeChecksImpl, - handleLabeledEvent + updateLabels } from "../src/summarize-checks/summarize-checks.js"; import { createMockCore, createMockGithub } from "./mocks.js"; import { Octokit } from "@octokit/rest"; @@ -598,7 +598,7 @@ describe("Summarize Checks Tests", () => { } ]; - it.each(testCases)( + it.only.each(testCases)( "$description", async ({ eventName, changedLabel, existingLabels, expectedLabelsToAdd, expectedLabelsToRemove }) => { const repo = "azure-rest-api-specs"; @@ -623,15 +623,11 @@ describe("Summarize Checks Tests", () => { eventName: eventName, }; - const [labelsToAdd, labelsToRemove] = await handleLabeledEvent( - mockGithub, - mockContext, - mockCore, - owner, - repo, - 1, - eventName, - existingLabels, + const [labelsToAdd, labelsToRemove] = await updateLabels( + eventName, + "main", + existingLabels, + changedLabel ); expect(labelsToAdd).toEqual(expectedLabelsToAdd); From d1f693ff529c3ef55cc41586e3ad2cea2a8808e0 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 10 Jul 2025 01:07:18 +0000 Subject: [PATCH 03/67] getting closer! 5/10 tests passing --- .../src/summarize-checks/summarize-checks.js | 45 +++++++++++++++---- .../workflows/test/summarize-checks.test.js | 20 +++++---- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 7b0f4d5be758..da65ddac9461 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -468,22 +468,51 @@ export async function updateLabels( const labelsToAdd = new Set(); const labelsToRemove = new Set(); - const sortedLabels = existingLabels.sort((a, b) => a.localeCompare(b)); // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels - if (containsAll(sortedLabels, ["ARMSignedOff"])) { - labelsToRemove.add("ARMChangesRequested"); - labelsToRemove.add("WaitForARMFeedback"); + if (containsAll(existingLabels, ["ARMSignedOff"])) { + if (existingLabels.includes("ARMChangesRequested")) { + labelsToRemove.add("ARMChangesRequested"); + } + if (existingLabels.includes("WaitForARMFeedback")) { + labelsToRemove.add("WaitForARMFeedback"); + } } // if we are waiting for ARM feedback, we should add the "WaitForARMFeedback" label - else if (containsAll(sortedLabels, ["ARMChangesRequested"]) && containsNone(sortedLabels, ["ARMSignedOff"])) { - labelsToRemove.add("WaitForARMFeedback"); + else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { + if (existingLabels.includes("WaitForARMFeedback")) { + labelsToRemove.add("WaitForARMFeedback"); + } } // finally, if ARMChangesRequested are not present, and we've gotten here by lack of signoff, we should add the "WaitForARMFeedback" label - else if (containsNone(sortedLabels, ["ARMChangesRequested"])) { - labelsToAdd.add("WaitForARMFeedback"); + else if (containsNone(existingLabels, ["ARMChangesRequested"])) { + if (!existingLabels.includes("WaitForARMFeedback")) { + labelsToAdd.add("WaitForARMFeedback"); + } } + // if (event_name === "labeled") { + // if (changedLabel == "ARMChangesRequested") { + // if (known_labels.indexOf("WaitForARMFeedback") !== -1) { + // labelsToRemove.add("WaitForARMFeedback"); + // } + // } + // if (changedLabel == "ARMSignedOff") { + // if (known_labels.indexOf("WaitForARMFeedback") !== -1) { + // labelsToRemove.add("WaitForARMFeedback"); + // } + // if (known_labels.indexOf("ARMChangesRequested") !== -1) { + // labelsToRemove.add("ARMChangesRequested"); + // } + // } + // } else if (event_name === "unlabeled") { + // if (changedLabel == "ARMChangesRequested") { + // if (known_labels.indexOf("WaitForARMFeedback") !== -1) { + // labelsToAdd.add("WaitForARMFeedback"); + // } + // } + // } + return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; } diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 5c075750a515..11fe72bde8d3 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -520,7 +520,7 @@ describe("Summarize Checks Tests", () => { description: "labeled: ARMChangesRequested with WaitForARMFeedback present", eventName: "labeled", changedLabel: "ARMChangesRequested", - existingLabels: ["WaitForARMFeedback", "other-label"], + existingLabels: ["WaitForARMFeedback", "ARMChangesRequested", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback"] }, @@ -528,7 +528,7 @@ describe("Summarize Checks Tests", () => { description: "labeled: ARMChangesRequested without WaitForARMFeedback", eventName: "labeled", changedLabel: "ARMChangesRequested", - existingLabels: ["other-label"], + existingLabels: ["other-label", "ARMChangesRequested"], expectedLabelsToAdd: [], expectedLabelsToRemove: [] }, @@ -536,7 +536,7 @@ describe("Summarize Checks Tests", () => { description: "labeled: ARMSignedOff with both labels present", eventName: "labeled", changedLabel: "ARMSignedOff", - existingLabels: ["WaitForARMFeedback", "ARMChangesRequested", "other-label"], + existingLabels: ["WaitForARMFeedback", "ARMSignedOff", "ARMChangesRequested", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback", "ARMChangesRequested"] }, @@ -544,7 +544,7 @@ describe("Summarize Checks Tests", () => { description: "labeled: ARMSignedOff with only WaitForARMFeedback present", eventName: "labeled", changedLabel: "ARMSignedOff", - existingLabels: ["WaitForARMFeedback", "other-label"], + existingLabels: ["WaitForARMFeedback", "ARMSignedOff", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback"] }, @@ -552,7 +552,7 @@ describe("Summarize Checks Tests", () => { description: "labeled: ARMSignedOff with only ARMChangesRequested present", eventName: "labeled", changedLabel: "ARMSignedOff", - existingLabels: ["ARMChangesRequested", "other-label"], + existingLabels: ["ARMChangesRequested", "ARMSignedOff", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["ARMChangesRequested"] }, @@ -560,7 +560,7 @@ describe("Summarize Checks Tests", () => { description: "labeled: ARMSignedOff with neither label present", eventName: "labeled", changedLabel: "ARMSignedOff", - existingLabels: ["other-label"], + existingLabels: ["other-label", "ARMSignedOff"], expectedLabelsToAdd: [], expectedLabelsToRemove: [] }, @@ -630,9 +630,11 @@ describe("Summarize Checks Tests", () => { changedLabel ); - expect(labelsToAdd).toEqual(expectedLabelsToAdd); - expect(labelsToRemove).toEqual(expectedLabelsToRemove); - } + + expect(labelsToAdd.sort()).toEqual(expectedLabelsToAdd.sort()); + expect(labelsToRemove.sort()).toEqual(expectedLabelsToRemove.sort()); + }, + 600000, ); }); }); From 40881f1ca434ef79a6aaefdb95b3b3feb27c7847 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 10 Jul 2025 01:42:24 +0000 Subject: [PATCH 04/67] updates. I seriously think this might work --- .../src/summarize-checks/summarize-checks.js | 36 +++++++++---------- .../workflows/test/summarize-checks.test.js | 9 +++-- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index da65ddac9461..99350196ae36 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -492,25 +492,25 @@ export async function updateLabels( } // if (event_name === "labeled") { - // if (changedLabel == "ARMChangesRequested") { - // if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - // labelsToRemove.add("WaitForARMFeedback"); - // } - // } - // if (changedLabel == "ARMSignedOff") { - // if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - // labelsToRemove.add("WaitForARMFeedback"); - // } - // if (known_labels.indexOf("ARMChangesRequested") !== -1) { - // labelsToRemove.add("ARMChangesRequested"); - // } - // } + // if (targetLabel == "ARMChangesRequested") { + // if (presentLabels.has("WaitForARMFeedback")) { + // labelsToRemove.add("WaitForARMFeedback"); + // } + // } + + // if (targetLabel == "ARMSignedOff") { + // if (presentLabels.has("WaitForARMFeedback")) { + // labelsToRemove.add("WaitForARMFeedback"); + // } + // if (presentLabels.has("ARMChangesRequested")) { + // labelsToRemove.add("ARMChangesRequested"); + // } // } else if (event_name === "unlabeled") { - // if (changedLabel == "ARMChangesRequested") { - // if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - // labelsToAdd.add("WaitForARMFeedback"); - // } - // } + // if (targetLabel == "ARMChangesRequested") { + // if (!presentLabels.has("WaitForARMFeedback")) { + // labelsToAdd.add("WaitForARMFeedback"); + // } + // } // } return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 11fe72bde8d3..1d0eccaf487f 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -569,7 +569,7 @@ describe("Summarize Checks Tests", () => { eventName: "unlabeled", changedLabel: "ARMChangesRequested", existingLabels: ["WaitForARMFeedback", "other-label"], - expectedLabelsToAdd: ["WaitForARMFeedback"], + expectedLabelsToAdd: [], expectedLabelsToRemove: [] }, { @@ -577,7 +577,7 @@ describe("Summarize Checks Tests", () => { eventName: "unlabeled", changedLabel: "ARMChangesRequested", existingLabels: ["other-label"], - expectedLabelsToAdd: [], + expectedLabelsToAdd: ["WaitForARMFeedback"], expectedLabelsToRemove: [] }, { @@ -586,7 +586,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "SomeOtherLabel", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], expectedLabelsToAdd: [], - expectedLabelsToRemove: [] + expectedLabelsToRemove: ["WaitForARMFeedback"] }, { description: "unlabeled: other label should have no effect", @@ -594,7 +594,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "SomeOtherLabel", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], expectedLabelsToAdd: [], - expectedLabelsToRemove: [] + expectedLabelsToRemove: ["WaitForARMFeedback"] } ]; @@ -630,7 +630,6 @@ describe("Summarize Checks Tests", () => { changedLabel ); - expect(labelsToAdd.sort()).toEqual(expectedLabelsToAdd.sort()); expect(labelsToRemove.sort()).toEqual(expectedLabelsToRemove.sort()); }, From df96c1ac741fb08a75aba755a0f74464e9b848de Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 10 Jul 2025 01:48:04 +0000 Subject: [PATCH 05/67] correct tests and lint --- .../src/summarize-checks/summarize-checks.js | 65 +++++++------------ .../workflows/test/summarize-checks.test.js | 25 +------ 2 files changed, 23 insertions(+), 67 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 99350196ae36..ae865b54818c 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -264,14 +264,14 @@ export async function summarizeChecksImpl( /** @type {string[]} */ let labelNames = labels.map((/** @type {{ name: string; }} */ label) => label.name); - /** @type { string | undefined } */ - const changedLabel = context.payload.label?.name; + // /** @type { string | undefined } */ + // const changedLabel = context.payload.label?.name; const [labelsToAdd, labelsToRemove] = await updateLabels( - event_name, - targetBranch, + // event_name, + // targetBranch, labelNames, - changedLabel + // changedLabel ); for (const label of labelsToRemove) { @@ -438,28 +438,29 @@ function containsNone(arr, values) { return values.every((value) => !arr.includes(value)); } -/** - * - * @param {any[]} arr - * @param {any[]} values - * @returns - */ -function containsAny(arr, values) { - return values.some((value) => arr.includes(value)); -} +// /** +// * +// * @param {any[]} arr +// * @param {any[]} values +// * @returns +// */ +// function containsAny(arr, values) { +// return values.some((value) => arr.includes(value)); +// } + +// * @param {string} eventName +// * @param {string} targetBranch +// * @param {string | undefined } changedLabel /** - * @param {string} eventName - * @param {string} targetBranch * @param {string[]} existingLabels - * @param {string | undefined } changedLabel * @returns {Promise<[string[], string[]]>} */ export async function updateLabels( - eventName, - targetBranch, + // eventName, + // targetBranch, existingLabels, - changedLabel + // changedLabel ) { // logic for this function originally present in: // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts @@ -478,7 +479,7 @@ export async function updateLabels( labelsToRemove.add("WaitForARMFeedback"); } } - // if we are waiting for ARM feedback, we should add the "WaitForARMFeedback" label + // if we are waiting for ARM feedback, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { if (existingLabels.includes("WaitForARMFeedback")) { labelsToRemove.add("WaitForARMFeedback"); @@ -491,28 +492,6 @@ export async function updateLabels( } } - // if (event_name === "labeled") { - // if (targetLabel == "ARMChangesRequested") { - // if (presentLabels.has("WaitForARMFeedback")) { - // labelsToRemove.add("WaitForARMFeedback"); - // } - // } - - // if (targetLabel == "ARMSignedOff") { - // if (presentLabels.has("WaitForARMFeedback")) { - // labelsToRemove.add("WaitForARMFeedback"); - // } - // if (presentLabels.has("ARMChangesRequested")) { - // labelsToRemove.add("ARMChangesRequested"); - // } - // } else if (event_name === "unlabeled") { - // if (targetLabel == "ARMChangesRequested") { - // if (!presentLabels.has("WaitForARMFeedback")) { - // labelsToAdd.add("WaitForARMFeedback"); - // } - // } - // } - return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; } diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 1d0eccaf487f..fdabe2d61dc1 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -4,11 +4,10 @@ import { summarizeChecksImpl, updateLabels } from "../src/summarize-checks/summarize-checks.js"; -import { createMockCore, createMockGithub } from "./mocks.js"; +import { createMockCore } from "./mocks.js"; import { Octokit } from "@octokit/rest"; const mockCore = createMockCore(); -const mockGithub = createMockGithub(); describe("Summarize Checks Tests", () => { describe("next steps comment rendering", () => { @@ -601,28 +600,6 @@ describe("Summarize Checks Tests", () => { it.only.each(testCases)( "$description", async ({ eventName, changedLabel, existingLabels, expectedLabelsToAdd, expectedLabelsToRemove }) => { - const repo = "azure-rest-api-specs"; - const owner = "Azure"; - const mockContext = { - repo: { - owner: owner, - repo: repo, - }, - payload: { - action: eventName, - pull_request: { - number: 1, - head: { - sha: "abc123", - }, - }, - label: { - name: changedLabel - } - }, - eventName: eventName, - }; - const [labelsToAdd, labelsToRemove] = await updateLabels( eventName, "main", From d5a64a3f3522793a82479d9d691ba1ddab532344 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 10 Jul 2025 01:55:49 +0000 Subject: [PATCH 06/67] including tests --- .github/workflows/src/summarize-checks/summarize-checks.js | 6 ++++++ .github/workflows/test/summarize-checks.test.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index ae865b54818c..0038377c8512 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -467,6 +467,12 @@ export async function updateLabels( // - public/rest-api-specs-scripts/src/prSummary.ts // it has since been simplified and moved here to handle all label addition and subtraction given a PR context + // the important part about how this will work depends how the users use it + // EG: if they add the "ARMSignedOff" label, we will remove the "ARMChangesRequested" and "WaitForARMFeedback" labels. + // if they add the "ARMChangesRequested" label, we will remove the "WaitForARMFeedback" label. + // if they remove the "ARMChangesRequested" label, we will add the "WaitForARMFeedback" label. + // so if the user or ARM team actually unlabels `ARMChangesRequested`, then we're actually ok + const labelsToAdd = new Set(); const labelsToRemove = new Set(); diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index fdabe2d61dc1..066f7c1a63c2 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -597,7 +597,7 @@ describe("Summarize Checks Tests", () => { } ]; - it.only.each(testCases)( + it.each(testCases)( "$description", async ({ eventName, changedLabel, existingLabels, expectedLabelsToAdd, expectedLabelsToRemove }) => { const [labelsToAdd, labelsToRemove] = await updateLabels( From 6698b4527570bb1b19c146f2f81424065e83226f Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 10 Jul 2025 22:31:32 +0000 Subject: [PATCH 07/67] factor review labels out --- .../src/summarize-checks/summarize-checks.js | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 0038377c8512..0456c4ff3b84 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -466,16 +466,27 @@ export async function updateLabels( // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts // - public/rest-api-specs-scripts/src/prSummary.ts // it has since been simplified and moved here to handle all label addition and subtraction given a PR context + const [labelsToAdd, labelsToRemove] = processArmReviewLabels(existingLabels); + return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; +} + +/** + * @param {string[]} existingLabels + * @returns {[Set, Set]} + */ +export function processArmReviewLabels( + existingLabels +) { + /** @type {Set} */ + const labelsToAdd = new Set(); + /** @type {Set} */ + const labelsToRemove = new Set(); // the important part about how this will work depends how the users use it // EG: if they add the "ARMSignedOff" label, we will remove the "ARMChangesRequested" and "WaitForARMFeedback" labels. // if they add the "ARMChangesRequested" label, we will remove the "WaitForARMFeedback" label. // if they remove the "ARMChangesRequested" label, we will add the "WaitForARMFeedback" label. // so if the user or ARM team actually unlabels `ARMChangesRequested`, then we're actually ok - - const labelsToAdd = new Set(); - const labelsToRemove = new Set(); - // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels if (containsAll(existingLabels, ["ARMSignedOff"])) { if (existingLabels.includes("ARMChangesRequested")) { @@ -485,20 +496,20 @@ export async function updateLabels( labelsToRemove.add("WaitForARMFeedback"); } } - // if we are waiting for ARM feedback, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed + // if there are ARM changes requested, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { if (existingLabels.includes("WaitForARMFeedback")) { labelsToRemove.add("WaitForARMFeedback"); } } - // finally, if ARMChangesRequested are not present, and we've gotten here by lack of signoff, we should add the "WaitForARMFeedback" label + // finally, if ARMChangesRequested are not present, and we've gotten here by lac;k of signoff, we should add the "WaitForARMFeedback" label else if (containsNone(existingLabels, ["ARMChangesRequested"])) { if (!existingLabels.includes("WaitForARMFeedback")) { labelsToAdd.add("WaitForARMFeedback"); } } - return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; + return [labelsToAdd, labelsToRemove] } From a69c26a0db9e8bcb0dd21b7dccb591af17f3d44a Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 11 Jul 2025 22:05:45 +0000 Subject: [PATCH 08/67] adding labelcontext --- .../summarize-checks/investigate-labelling.md | 46 --------------- .../src/summarize-checks/label-rules.js | 31 ++++++++++ .../src/summarize-checks/summarize-checks.js | 57 ++++++++++--------- 3 files changed, 60 insertions(+), 74 deletions(-) delete mode 100644 .github/workflows/src/summarize-checks/investigate-labelling.md diff --git a/.github/workflows/src/summarize-checks/investigate-labelling.md b/.github/workflows/src/summarize-checks/investigate-labelling.md deleted file mode 100644 index 37ad7e0d6a5d..000000000000 --- a/.github/workflows/src/summarize-checks/investigate-labelling.md +++ /dev/null @@ -1,46 +0,0 @@ -# Needing "label" context when handling the listed labels - - -This is the original logic -```js - if (event_name === "labeled") { - if (changedLabel == "ARMChangesRequested") { - if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - labelsToRemove.add("WaitForARMFeedback"); - } - } - if (changedLabel == "ARMSignedOff") { - if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - labelsToRemove.add("WaitForARMFeedback"); - } - if (known_labels.indexOf("ARMChangesRequested") !== -1) { - labelsToRemove.add("ARMChangesRequested"); - } - } - } else if (event_name === "unlabeled") { - if (changedLabel == "ARMChangesRequested") { - if (known_labels.indexOf("WaitForARMFeedback") !== -1) { - labelsToAdd.add("WaitForARMFeedback"); - } - } - } -``` - -So: - -``` -fn(event, changedLabel, knownLabels) - -fn("labeled", "ArmChangesRequested", ["ARMChangesRequested", "WaitForARMFeedback"]) - -> labelsToAdd: [], labelsToRemove: ["WaitForarmFeedback"] - -fn("unlabeled", "ARMChangesRequested", ["WaitForARMFeedback"]) - -> labelsToAdd: ["WaitForArmFeedback"], labelsToRemove: [] -``` - -Can we replace this with straightforward results? - - -input ["ARMChangesRequested", "WaitForARMFeedback"] -> Remove WaitForArmFeedback -input [No ArmChangesRequested] -> Add WaitForArmFeedback - diff --git a/.github/workflows/src/summarize-checks/label-rules.js b/.github/workflows/src/summarize-checks/label-rules.js index a21205c8eed2..95dd15168952 100644 --- a/.github/workflows/src/summarize-checks/label-rules.js +++ b/.github/workflows/src/summarize-checks/label-rules.js @@ -13,6 +13,37 @@ import { wrapInArmReviewMessage, } from "./tsgs.js"; +/** + * The LabelContext is used by the updateLabels() to determine which labels to add or remove to the PR. + * + * The "present" set represents the set of labels that are currently present on the PR and should be populated + * ONCE at the beginning of the summarize-checks action script. + * + * The "toAdd" set is the set of labels to be added to the PR at the end of invocation of updateLabels(). + * This is to be done by calling GitHub Octokit API to add the labels. + * + * The "toRemove" set is analogous to "toAdd" set, but instead it is the set of labels to be removed. + * + * The general pattern used in the code to populate "toAdd" or "toRemove" sets to be ready for + * Octokit invocation is as follows: + * + * - the summary() function passes the context through its invocation chain. + * - given function responsible for given label, like e.g. for label "ARMReview", + * creates a new instance of Label: const armReviewLabel = new Label("ARMReview", labelContext.present) + * - the function then processes the label to determine if armReviewLabel.shouldBePresent is to be set to true or false. + * - the function at the end of its invocation calls armReviewLabel.applyStateChanges(labelContext.toAdd, labelContext.toRemove) + * to update the sets. + * - the function may optionally return { armReviewLabel.shouldBePresent } to allow the caller to pass this value + * further to downstream business logic that depends on it. + * - at the end of invocation summary() calls Octokit passing it as input labelContext.toAdd and labelContext.toRemove. + */ +/** + * @typedef {Object} LabelContext + * @property {Set} present - The current set of labels + * @property {Set} toAdd - The set of labels to add + * @property {Set} toRemove - The set of labels to remove + */ + /** * This file is the single source of truth for the labels used by the SDK generation tooling * in the Azure/azure-rest-api-specs and Azure/azure-rest-api-specs-pr repositories. diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 0456c4ff3b84..acae304cf0a4 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -34,6 +34,7 @@ import { typeSpecRequirementArmTsg, typeSpecRequirementDataPlaneTsg, } from "./tsgs.js"; +import { toASCII } from "punycode"; /** * @typedef {Object} CheckMetadata @@ -267,14 +268,9 @@ export async function summarizeChecksImpl( // /** @type { string | undefined } */ // const changedLabel = context.payload.label?.name; - const [labelsToAdd, labelsToRemove] = await updateLabels( - // event_name, - // targetBranch, - labelNames, - // changedLabel - ); + let labelContext = await updateLabels(targetBranch, labelNames) - for (const label of labelsToRemove) { + for (const label of labelContext.toRemove) { core.info(`Removing label: ${label} from ${owner}/${repo}#${issue_number}.`); // await github.rest.issues.removeLabel({ // owner: owner, @@ -284,9 +280,9 @@ export async function summarizeChecksImpl( // }); } - if (labelsToAdd.length > 0) { + if (labelContext.toAdd.size > 0) { core.info( - `Adding labels: ${Array.from(labelsToAdd).join(", ")} to ${owner}/${repo}#${issue_number}.`, + `Adding labels: ${Array.from(labelContext.toAdd).join(", ")} to ${owner}/${repo}#${issue_number}.`, ); // await github.rest.issues.addLabels({ // owner: owner, @@ -297,8 +293,8 @@ export async function summarizeChecksImpl( } // adjust labelNames based on labelsToAdd/labelsToRemove - labelNames = labelNames.filter((name) => !labelsToRemove.includes(name)); - for (const label of labelsToAdd) { + labelNames = labelNames.filter((name) => !labelContext.toRemove.has(name)); + for (const label of labelContext.toAdd) { if (!labelNames.includes(label)) { labelNames.push(label); } @@ -453,35 +449,42 @@ function containsNone(arr, values) { // * @param {string | undefined } changedLabel /** + * @param {string} targetBranch * @param {string[]} existingLabels - * @returns {Promise<[string[], string[]]>} + * @returns {import("./label-rules.js").LabelContext} */ -export async function updateLabels( +export function updateLabels( // eventName, - // targetBranch, + targetBranch, existingLabels, // changedLabel ) { + /** @type {import("./label-rules.js").LabelContext} */ + const context = { + present: new Set(existingLabels), + toAdd: new Set(), + toRemove: new Set() + } + console.log(targetBranch); // placeholder for now. + // logic for this function originally present in: // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts // - public/rest-api-specs-scripts/src/prSummary.ts // it has since been simplified and moved here to handle all label addition and subtraction given a PR context - const [labelsToAdd, labelsToRemove] = processArmReviewLabels(existingLabels); - return [Array.from(labelsToAdd), Array.from(labelsToRemove)]; + processArmReviewLabels(context, existingLabels); + + return context; } /** + * @param {import("./label-rules.js").LabelContext} context * @param {string[]} existingLabels - * @returns {[Set, Set]} + * @returns {void} */ export function processArmReviewLabels( + context, existingLabels ) { - /** @type {Set} */ - const labelsToAdd = new Set(); - /** @type {Set} */ - const labelsToRemove = new Set(); - // the important part about how this will work depends how the users use it // EG: if they add the "ARMSignedOff" label, we will remove the "ARMChangesRequested" and "WaitForARMFeedback" labels. // if they add the "ARMChangesRequested" label, we will remove the "WaitForARMFeedback" label. @@ -490,26 +493,24 @@ export function processArmReviewLabels( // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels if (containsAll(existingLabels, ["ARMSignedOff"])) { if (existingLabels.includes("ARMChangesRequested")) { - labelsToRemove.add("ARMChangesRequested"); + context.toAdd.add("ARMChangesRequested"); } if (existingLabels.includes("WaitForARMFeedback")) { - labelsToRemove.add("WaitForARMFeedback"); + context.toRemove.add("WaitForARMFeedback"); } } // if there are ARM changes requested, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { if (existingLabels.includes("WaitForARMFeedback")) { - labelsToRemove.add("WaitForARMFeedback"); + context.toRemove.add("WaitForARMFeedback"); } } // finally, if ARMChangesRequested are not present, and we've gotten here by lac;k of signoff, we should add the "WaitForARMFeedback" label else if (containsNone(existingLabels, ["ARMChangesRequested"])) { if (!existingLabels.includes("WaitForARMFeedback")) { - labelsToAdd.add("WaitForARMFeedback"); + context.toAdd.add("WaitForARMFeedback"); } } - - return [labelsToAdd, labelsToRemove] } From c6eeffc6bebeeb78b2c7f40a535c6e196566a27b Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 11 Jul 2025 22:38:45 +0000 Subject: [PATCH 09/67] pulling some items across. still broken state --- .../{label-rules.js => labelling.js} | 369 ++++++++++++++---- .../src/summarize-checks/summarize-checks.js | 29 +- 2 files changed, 300 insertions(+), 98 deletions(-) rename .github/workflows/src/summarize-checks/{label-rules.js => labelling.js} (84%) diff --git a/.github/workflows/src/summarize-checks/label-rules.js b/.github/workflows/src/summarize-checks/labelling.js similarity index 84% rename from .github/workflows/src/summarize-checks/label-rules.js rename to .github/workflows/src/summarize-checks/labelling.js index 95dd15168952..9238ee83ae31 100644 --- a/.github/workflows/src/summarize-checks/label-rules.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -1,8 +1,7 @@ /* - This file determines what labels are required for a given PR. It uses this context to obtain two key - pieces of information: - 1. The labels that are currently present on the PR. - 2. The target branch of the PR. + This file covers two areas of enforcement: + 1. It calculates what set of label rules has been violated by the current PR, for the purposes of updating next steps to merge. + 2. It calculates what set of labels should be added or removed from the PR. */ import { @@ -13,6 +12,9 @@ import { wrapInArmReviewMessage, } from "./tsgs.js"; +import { readFileSync } from "fs"; + +// #region typedefs /** * The LabelContext is used by the updateLabels() to determine which labels to add or remove to the PR. * @@ -54,6 +56,221 @@ import { * - https://microsoftapc-my.sharepoint.com/:w:/g/personal/raychen_microsoft_com/EbOAA9SkhQhGlgxtf7mc0kUB-25bFue0EFbXKXS3TFLTQA */ +/** + * RequiredLabelRule: + * IF ((any of the anyPrerequisiteLabels is present + * OR all of the allPrerequisiteLabels are present) + * AND none of the allPrerequisiteAbsentLabels is present) + * THEN any of the anyRequiredLabels is required. + * + * IF any of the anyRequiredLabels is required + * THEN display the troubleshootingGuide in "Next Steps to Merge" comment. + * + * @typedef {Object} RequiredLabelRule + * @property {number} precedence - If multiple RequiredLabelRules are violated, the one with lowest + * precedence should be displayed to the user first. If multiple rules have + * the same precedence, and one of them should be displayed, + * then all of them should be displayed. + * + * Note this independent of the CheckMetadata.precedence. That is, + * if there are failing checks, and failing required label rules, + * both of them will be shown, both taking appropriate lowest precedence. + * @property {string[]} [branches] - Branches, in format "repo/branch", e.g. "azure-rest-api-specs/main", + * to which this required label rule applies. + * + * To be exact: + * - If there is at least one branch defined, and the evaluated PR is + * not targeting any of the branches defined, then the rule is not applicable (it implicitly passes). + * - If "branches" is empty or undefined, then the rule applies to all branches in all repos. + * @property {string[]} [anyPrerequisiteLabels] - If any of anyPrerequisiteLabels is present, the requiredLabel is required. + * This condition is ORed with allPrerequisiteLabels. + * + * If the anyRequiredLabels collection is empty or undefined, anyPrerequisiteLabels must have exactly one entry. + * @property {string[]} [allPrerequisiteLabels] - If all of allPrerequisiteLabels are present, the requiredLabel is required. + * This condition is ORed with anyPrerequisiteLabels. + * + * If the anyRequiredLabels collection is empty or undefined, allPrerequisiteLabels must be empty or undefined. + * @property {string[]} [allPrerequisiteAbsentLabels] - If any of the allPrerequisiteAbsentLabels is present, + * the requiredLabel is not required. + * @property {string[]} anyRequiredLabels - If any of the labels in anyRequiredLabels is present, + * then the rule prerequisites, as expressed by anyPrerequisiteLabels and allPrerequisiteLabels, are met. + * Conversely, if none of anyRequiredLabels are present, then the rule is violated. + * + * For the purposes of determining which label to display as required in the 'automated merging requirements met' check and + * 'Next Steps to Merge" comments, the first label in anyRequiredLabels is used. + * The assumption here is that anyRequiredLabels[0] is the 'current' label, + * while the remaining required label options are 'legacy' labels. + * + * If given required label string ends with an asterisk, it is treated as a prefix substring match. + * For example, requiredLabel of 'Foo-Approved-*' means that any label with prefix 'Foo-Approved-' will satisfy the rule. + * For example, 'Foo-Approved-Bar' or 'Foo-Approved-Qux', but not 'Foo-Approved' nor 'Foo-Approved-'. + * + * If anyRequiredLabels is an empty array, then if the rule is violated, there is no way to meet the rule prerequisites. + * @property {string} troubleshootingGuide - The doc to display to the user if the required label is required, but missing. + */ + +/** + * This file is the single source of truth for types used by the OpenAPI specification breaking change checks + * in the Azure/azure-rest-api-specs and Azure/azure-rest-api-specs-pr repositories. + * + * For additional context, see: + * + * - "Deep-dive into breaking changes on spec PRs" + * https://aka.ms/azsdk/pr-brch-deep + * + * - "[Breaking Change][PR Workflow] Use more granular labels for Breaking Changes approvals" + * https://github.com/Azure/azure-sdk-tools/issues/6374 + */ +/** + * @typedef {"SameVersion" | "CrossVersion"} BreakingChangesCheckType + * @typedef {"VersioningReviewRequired" | "BreakingChangeReviewRequired"} ReviewRequiredLabel + * @typedef {"Versioning-Approved-*" | "BreakingChange-Approved-*"} ReviewApprovalPrefixLabel + * @typedef {"Versioning-Approved-Benign" | "Versioning-Approved-BugFix" | "Versioning-Approved-PrivatePreview" | "Versioning-Approved-BranchPolicyException" | "Versioning-Approved-Previously" | "Versioning-Approved-Retired"} ValidVersioningApproval + * @typedef {"BreakingChange-Approved-Benign" | "BreakingChange-Approved-BugFix" | "BreakingChange-Approved-UserImpact" | "BreakingChange-Approved-BranchPolicyException" | "BreakingChange-Approved-Previously" | "BreakingChange-Approved-Security"} ValidBreakingChangeApproval + * @typedef {ReviewRequiredLabel | ReviewApprovalPrefixLabel | ValidBreakingChangeApproval | ValidVersioningApproval} SpecsBreakingChangesLabel + */ +/** + * @typedef {Object} BreakingChangesCheckConfig + * @property {ReviewRequiredLabel} reviewRequiredLabel + * @property {ReviewApprovalPrefixLabel} approvalPrefixLabel + * @property {(ValidVersioningApproval | ValidBreakingChangeApproval)[]} approvalLabels + * @property {string} [deprecatedReviewRequiredLabel] + */ + +/** + * @typedef {"SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"} FileTypes + */ + +/** + * @typedef {"Addition" | "Deletion" | "Update"} ChangeTypes + */ + +/** + * @typedef {Object} PRChange + * @property {FileTypes} fileType + * @property {ChangeTypes} changeType + * @property {string} filePath + * @property {any} [additionalInfo] + */ + +/** + * @typedef {Object} ChangeHandler + * @property {function(PRChange): void} [SwaggerFile] + * @property {function(PRChange): void} [TypeSpecFile] + * @property {function(PRChange): void} [ExampleFile] + * @property {function(PRChange): void} [ReadmeFile] + */ + +/** + * @typedef {"resource-manager" | "data-plane"} PRType + */ + +/** + * Represents a GitHub label. + * Currently used in the context of processing + * labels related to the review workflow of PRs submitted to + * Azure/azure-rest-api-specs and Azure/azure-rest-api-specs-pr repositories. + * This processing happens in the prSummary.ts / summary() function. + * + * See also: https://aka.ms/SpecPRReviewARMInvariants + */ +export class Label { + /** + * @param {string} name + * @param {Set} [presentLabels] + */ + constructor(name, presentLabels) { + /** @type {string} */ + this.name = name; + + /** + * Is the label currently present on the pull request? + * This is determined at the time of construction of this object. + * @type {boolean | undefined} + */ + this.present = presentLabels?.has(this.name) ?? undefined; + + /** + * Should this label be present on the pull request? + * Must be defined before applyStateChange is called. + * Not set at the construction time to facilitate determining desired presence + * of multiple labels in single code block, without intermixing it with + * label construction logic. + * @type {boolean | undefined} + */ + this.shouldBePresent = undefined; + } + + /** + * If the label should be added, add its name to labelsToAdd. + * If the label should be removed, add its name to labelsToRemove. + * Otherwise, do nothing. + * + * Precondition: this.shouldBePresent has been defined. + * @param {Set} labelsToAdd + * @param {Set} labelsToRemove + */ + applyStateChange(labelsToAdd, labelsToRemove) { + if (this.shouldBePresent === undefined) { + console.warn( + "ASSERTION VIOLATION! " + + `Cannot applyStateChange for label '${this.name}' ` + + "as its desired presence hasn't been defined. Returning early." + ); + return; + } + + if (!this.present && this.shouldBePresent) { + if (!labelsToAdd.has(this.name)) { + console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`); + labelsToAdd.add(this.name); + } else { + console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`); + } + } else if (this.present && !this.shouldBePresent) { + if (!labelsToRemove.has(this.name)) { + console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`); + labelsToRemove.add(this.name); + } else { + console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`); + } + } else if (this.present === this.shouldBePresent) { + console.log(`Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`); + } else { + console.warn( + "ASSERTION VIOLATION! " + + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` + + `At this point of execution this should not happen.` + ); + } + } + + /** + * @param {string} label + * @returns {boolean} + */ + isEqualToOrPrefixOf(label) { + return this.name.endsWith("*") + ? label.startsWith(this.name.slice(0, -1)) + : this.name === label; + } + + /** + * @returns {string} + */ + logString() { + return ( + `Label: name: ${this.name}, ` + + `present: ${this.present}, ` + + `shouldBePresent: ${this.shouldBePresent}. ` + ); + } +} + + + +// #endregion typedefs +// #region constants export const sdkLabels = { "azure-cli-extensions": { breakingChange: undefined, @@ -130,38 +347,10 @@ export const sdkLabels = { }, }; -/** - * This file is the single source of truth for types used by the OpenAPI specification breaking change checks - * in the Azure/azure-rest-api-specs and Azure/azure-rest-api-specs-pr repositories. - * - * For additional context, see: - * - * - "Deep-dive into breaking changes on spec PRs" - * https://aka.ms/azsdk/pr-brch-deep - * - * - "[Breaking Change][PR Workflow] Use more granular labels for Breaking Changes approvals" - * https://github.com/Azure/azure-sdk-tools/issues/6374 - */ -/** - * @typedef {"SameVersion" | "CrossVersion"} BreakingChangesCheckType - * @typedef {"VersioningReviewRequired" | "BreakingChangeReviewRequired"} ReviewRequiredLabel - * @typedef {"Versioning-Approved-*" | "BreakingChange-Approved-*"} ReviewApprovalPrefixLabel - * @typedef {"Versioning-Approved-Benign" | "Versioning-Approved-BugFix" | "Versioning-Approved-PrivatePreview" | "Versioning-Approved-BranchPolicyException" | "Versioning-Approved-Previously" | "Versioning-Approved-Retired"} ValidVersioningApproval - * @typedef {"BreakingChange-Approved-Benign" | "BreakingChange-Approved-BugFix" | "BreakingChange-Approved-UserImpact" | "BreakingChange-Approved-BranchPolicyException" | "BreakingChange-Approved-Previously" | "BreakingChange-Approved-Security"} ValidBreakingChangeApproval - * @typedef {ReviewRequiredLabel | ReviewApprovalPrefixLabel | ValidBreakingChangeApproval | ValidVersioningApproval} SpecsBreakingChangesLabel - */ -/** - * @typedef {Object} BreakingChangesCheckConfig - * @property {ReviewRequiredLabel} reviewRequiredLabel - * @property {ReviewApprovalPrefixLabel} approvalPrefixLabel - * @property {(ValidVersioningApproval | ValidBreakingChangeApproval)[]} approvalLabels - * @property {string} [deprecatedReviewRequiredLabel] - */ - /** * @type {Record} */ -// todo: pull this from eng/tools/openapi-diff-runner/src/types/breaking-change.ts +// todo: pull values from eng/tools/openapi-diff-runner/src/types/breaking-change.ts export const breakingChangesCheckType = { SameVersion: { reviewRequiredLabel: "VersioningReviewRequired", @@ -189,6 +378,66 @@ export const breakingChangesCheckType = { }, }; +// #endregion constants +// #region LabelContext + +/** + * @param {string} swaggerFilePath + * @returns {boolean} + */ +function isSwaggerGeneratedByTypeSpec(swaggerFilePath) { + try { + return !!JSON.parse(readFileSync(swaggerFilePath).toString())?.info['x-typespec-generated'] + } + catch { + return false + } +} + +/** + * @param {any} ctx + * @param {LabelContext} labelContext + * @returns {Promise} + */ +async function processTypeSpec(ctx, labelContext) { + console.log("ENTER definition processTypeSpec") + const typeSpecLabel = new Label("TypeSpec", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + typeSpecLabel.shouldBePresent = false; + /** @type {ChangeHandler[]} */ + const handlers = []; + const typeSpecFileHandler = () => { + /** + * @param {PRChange} prChange + */ + return (prChange) => { + // Note: this code will be executed if the PR has a diff on a TypeSpec file, + // as defined in public/swagger-validation-common/src/context.ts/defaultFilePatterns/typespec + typeSpecLabel.shouldBePresent = true; + }; + }; + const swaggerFileHandler = () => { + /** + * @param {PRChange} prChange + */ + return (prChange) => { + if (prChange.changeType !== "Deletion" && isSwaggerGeneratedByTypeSpec(prChange.filePath)) { + typeSpecLabel.shouldBePresent = true; + } + }; + }; + handlers.push({ + TypeSpecFile: typeSpecFileHandler(), + SwaggerFile: swaggerFileHandler(), + }); + await processPrChanges(ctx, handlers); + + typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processTypeSpec") +} + +// #endregion +// #region LabelRules /** * @param {BreakingChangesCheckType} approvalType * @param {string[]} labels @@ -200,59 +449,6 @@ export function anyApprovalLabelPresent(approvalType, labels) { return anyLabelMatches(labelsToMatchAgainst, labels); } -/** - * RequiredLabelRule: - * IF ((any of the anyPrerequisiteLabels is present - * OR all of the allPrerequisiteLabels are present) - * AND none of the allPrerequisiteAbsentLabels is present) - * THEN any of the anyRequiredLabels is required. - * - * IF any of the anyRequiredLabels is required - * THEN display the troubleshootingGuide in "Next Steps to Merge" comment. - * - * @typedef {Object} RequiredLabelRule - * @property {number} precedence - If multiple RequiredLabelRules are violated, the one with lowest - * precedence should be displayed to the user first. If multiple rules have - * the same precedence, and one of them should be displayed, - * then all of them should be displayed. - * - * Note this independent of the CheckMetadata.precedence. That is, - * if there are failing checks, and failing required label rules, - * both of them will be shown, both taking appropriate lowest precedence. - * @property {string[]} [branches] - Branches, in format "repo/branch", e.g. "azure-rest-api-specs/main", - * to which this required label rule applies. - * - * To be exact: - * - If there is at least one branch defined, and the evaluated PR is - * not targeting any of the branches defined, then the rule is not applicable (it implicitly passes). - * - If "branches" is empty or undefined, then the rule applies to all branches in all repos. - * @property {string[]} [anyPrerequisiteLabels] - If any of anyPrerequisiteLabels is present, the requiredLabel is required. - * This condition is ORed with allPrerequisiteLabels. - * - * If the anyRequiredLabels collection is empty or undefined, anyPrerequisiteLabels must have exactly one entry. - * @property {string[]} [allPrerequisiteLabels] - If all of allPrerequisiteLabels are present, the requiredLabel is required. - * This condition is ORed with anyPrerequisiteLabels. - * - * If the anyRequiredLabels collection is empty or undefined, allPrerequisiteLabels must be empty or undefined. - * @property {string[]} [allPrerequisiteAbsentLabels] - If any of the allPrerequisiteAbsentLabels is present, - * the requiredLabel is not required. - * @property {string[]} anyRequiredLabels - If any of the labels in anyRequiredLabels is present, - * then the rule prerequisites, as expressed by anyPrerequisiteLabels and allPrerequisiteLabels, are met. - * Conversely, if none of anyRequiredLabels are present, then the rule is violated. - * - * For the purposes of determining which label to display as required in the 'automated merging requirements met' check and - * 'Next Steps to Merge" comments, the first label in anyRequiredLabels is used. - * The assumption here is that anyRequiredLabels[0] is the 'current' label, - * while the remaining required label options are 'legacy' labels. - * - * If given required label string ends with an asterisk, it is treated as a prefix substring match. - * For example, requiredLabel of 'Foo-Approved-*' means that any label with prefix 'Foo-Approved-' will satisfy the rule. - * For example, 'Foo-Approved-Bar' or 'Foo-Approved-Qux', but not 'Foo-Approved' nor 'Foo-Approved-'. - * - * If anyRequiredLabels is an empty array, then if the rule is violated, there is no way to meet the rule prerequisites. - * @property {string} troubleshootingGuide - The doc to display to the user if the required label is required, but missing. - */ - /** * @param {RequiredLabelRule} rule * @returns {boolean} @@ -803,3 +999,4 @@ export function isEqualToOrPrefixOf(inputstring, label) { ? label.startsWith(inputstring.slice(0, -1)) : inputstring === label; } +// #endregion diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index acae304cf0a4..ff661b8aa641 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -23,7 +23,7 @@ import { extractInputs } from "../context.js"; // eslint-disable-next-line no-unused-vars import { commentOrUpdate } from "../comment.js"; import { PER_PAGE_MAX } from "../github.js"; -import { verRevApproval, brChRevApproval, getViolatedRequiredLabelsRules } from "./label-rules.js"; +import { verRevApproval, brChRevApproval, getViolatedRequiredLabelsRules } from "./labelling.js"; import { brchTsg, @@ -53,7 +53,7 @@ import { toASCII } from "punycode"; */ /** - * @typedef {import("./label-rules.js").RequiredLabelRule} RequiredLabelRule + * @typedef {import("./labelling.js").RequiredLabelRule} RequiredLabelRule */ // Placing these configuration items here until we decide another way to pull them in. @@ -267,8 +267,8 @@ export async function summarizeChecksImpl( // /** @type { string | undefined } */ // const changedLabel = context.payload.label?.name; - - let labelContext = await updateLabels(targetBranch, labelNames) + const isDraft = context.payload.pull_request?.draft ?? false; + let labelContext = await updateLabels(targetBranch, isDraft, labelNames); for (const label of labelContext.toRemove) { core.info(`Removing label: ${label} from ${owner}/${repo}#${issue_number}.`); @@ -450,34 +450,39 @@ function containsNone(arr, values) { /** * @param {string} targetBranch + * @param {boolean} isDraft * @param {string[]} existingLabels - * @returns {import("./label-rules.js").LabelContext} + * @returns {import("./labelling.js").LabelContext} */ export function updateLabels( // eventName, targetBranch, + isDraft, existingLabels, // changedLabel ) { - /** @type {import("./label-rules.js").LabelContext} */ + // logic for this function originally present in: + // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts + // - public/rest-api-specs-scripts/src/prSummary.ts + // it has since been simplified and moved here to handle all label addition and subtraction given a PR context + + /** @type {import("./labelling.js").LabelContext} */ const context = { present: new Set(existingLabels), toAdd: new Set(), toRemove: new Set() } - console.log(targetBranch); // placeholder for now. + console.log(targetBranch); + console.log(isDraft); - // logic for this function originally present in: - // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts - // - public/rest-api-specs-scripts/src/prSummary.ts - // it has since been simplified and moved here to handle all label addition and subtraction given a PR context + // process wait for arm feedback vs arm feedback requested processArmReviewLabels(context, existingLabels); return context; } /** - * @param {import("./label-rules.js").LabelContext} context + * @param {import("./labelling.js").LabelContext} context * @param {string[]} existingLabels * @returns {void} */ From a507134366391e8375da135638ca3f8d1385a769 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 12 Jul 2025 01:03:10 +0000 Subject: [PATCH 10/67] pull across a bunch more stuff. the pr context is going to need a ton of work unfortunately --- .../src/summarize-checks/labelling.js | 153 +++++++++++++++++- .../src/summarize-checks/summarize-checks.js | 123 +++++++++++--- 2 files changed, 253 insertions(+), 23 deletions(-) diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index 9238ee83ae31..6ce4488f04fd 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -174,6 +174,7 @@ import { readFileSync } from "fs"; * * See also: https://aka.ms/SpecPRReviewARMInvariants */ +// todo: inject `core` for access to logging export class Label { /** * @param {string} name @@ -267,8 +268,6 @@ export class Label { } } - - // #endregion typedefs // #region constants export const sdkLabels = { @@ -435,6 +434,156 @@ async function processTypeSpec(ctx, labelContext) { typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); console.log("RETURN definition processTypeSpec") } +export async function processPrChanges( + ctx: IValidatorContext, + Handlers: ChangeHandler[] +) { + console.log("ENTER definition processPrChanges") + const prChange = await getPRChanges(ctx); + prChange.forEach((prChange) => { + Handlers.forEach((handler) => { + if (prChange.fileType in handler) { + handler?.[prChange.fileType]?.(prChange); + } + }); + }); + console.log("RETURN definition processPrChanges") +} + +export async function getPRChanges(ctx: IValidatorContext): Promise { + console.log("ENTER definition getPRChanges") + const results: PRChange[] = []; + + function newChange( + fileType: FileTypes, + changeType: ChangeTypes, + filePath?: string, + additionalInfo?: any + ) { + if (filePath) { + results.push({ + filePath, + fileType, + changeType, + additionalInfo, + }); + } + } + + function newChanges( + fileType: FileTypes, + changeType: ChangeTypes, + files?: string[] + ) { + if (files) { + files.forEach((filePath) => newChange(fileType, changeType, filePath)); + } + } + + function genChanges(type: FileTypes, diffs: DiffResult) { + newChanges(type, "Addition", diffs.additions); + newChanges(type, "Deletion", diffs.deletions); + newChanges(type, "Update", diffs.changes); + } + + function genReadmeChanges(readmeDiffs?: DiffResult) { + if (readmeDiffs) { + readmeDiffs.additions?.forEach((d) => + newChange("ReadmeFile", "Addition", d.readme, d.tags) + ); + readmeDiffs.changes?.forEach((d) => + newChange("ReadmeFile", "Update", d.readme, d.tags) + ); + readmeDiffs.deletions?.forEach((d) => + newChange("ReadmeFile", "Deletion", d.readme, d.tags) + ); + } + } + + genChanges("SwaggerFile", await ctx.getSwaggerDiffs()); + genChanges("TypeSpecFile", await ctx.getTypeSpecDiffs()); + genChanges("ExampleFile", await ctx.getExampleDiffs()); + genReadmeChanges(await ctx.getReadmeDiffs()); + + console.log("RETURN definition getPRChanges") + return results; +} + +async function processARMReview( + context: IValidatorContext, + labelContext: LabelContext, + resourceManagerLabelShouldBePresent: boolean, + versioningReviewRequiredLabelShouldBePresent: boolean, + breakingChangeReviewRequiredLabelShouldBePresent: boolean, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, + rpaasExceptionLabelShouldBePresent: boolean, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean, + isDraft: boolean, +): Promise<{ armReviewLabelShouldBePresent: boolean }> { + console.log("ENTER definition processARMReview") + const [owner, repo, prNumber] = getPRInfo(context) + + const armReviewLabel = new Label("ARMReview", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + armReviewLabel.shouldBePresent = false; + + const newApiVersionLabel = new Label("new-api-version", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + newApiVersionLabel.shouldBePresent = false; + + const branch = (context.contextConfig() as PRContext).targetBranch + const prTitle = await getPrTitle(owner, repo, prNumber) + const isReleaseBranchVal: boolean = isReleaseBranch(branch) + const isShiftLeftPRWithRPSaaSDevVal: boolean = isShiftLeftPRWithRPSaaSDev(prTitle, branch) + const isBranchInScopeOfSpecReview: boolean = isReleaseBranchVal || isShiftLeftPRWithRPSaaSDevVal + let isNewApiVersionVal: boolean | "not_computed" = "not_computed" + let isMissingBaseCommit: boolean | "not_computed" = "not_computed" + + // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic + // determines which kind of review exactly we need. + let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview + if (specReviewApplies) { + isNewApiVersionVal = await isNewApiVersion(context) + if (isNewApiVersionVal) { + // Note that in case of data-plane PRs, the addition of this label will result + // in API stewardship board review being required. + // See requiredLabelsRules.ts. + newApiVersionLabel.shouldBePresent = true; + } + + armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent + await processARMReviewWorkflowLabels( + labelContext, + armReviewLabel.shouldBePresent, + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent) + } + + newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) + armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) + + console.log(`RETURN definition processARMReview. ` + + `url: ${new PRKey(owner, repo, prNumber).toUrl()}, owner: ${owner}, repo: ${repo}, pr: ${prNumber}, branch: ${branch}, ` + + `isReleaseBranch: ${isReleaseBranchVal}, ` + + `isShiftLeftPRWithRPSaaSDev: ${isShiftLeftPRWithRPSaaSDevVal}, ` + + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` + + `isNewApiVersion: ${isNewApiVersionVal}, ` + + `isMissingBaseCommit: ${isMissingBaseCommit}, ` + + `isDraft: ${isDraft}, ` + + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` + + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) + + return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } +} + +function isReleaseBranch(branchName: string) { + const branchRegex = [/main/, /RPSaaSMaster/, /release*/, /ARMCoreRPDev/]; + return branchRegex.some((b) => b.test(branchName)); +} + // #endregion // #region LabelRules diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index ff661b8aa641..04a15b69201a 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -255,12 +255,34 @@ export async function summarizeChecksImpl( `Handling ${event_name} event for PR #${issue_number} in ${owner}/${repo} with targeted branch ${targetBranch}`, ); - const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { - owner: owner, - repo: repo, - issue_number: issue_number, - per_page: PER_PAGE_MAX, - }); + // we always want fresh labels + // we always need a file list. We are pulling the file list from gh because we won't be in the correct file context + // to await getChangedFiles, as we run on pull_request_target and workflow_run events. We _aren't_ in the actual PR context + // as far as files go + + // If there is a check that DOES need PR context, we will need to shift that logic onto a solo workflow that runs on pull_request + // events, and then we can await getChangedFiles. + const [labels, files] = await Promise.all([ + github.paginate( + github.rest.issues.listLabelsOnIssue, + { + owner, + repo, + issue_number: issue_number, + per_page: PER_PAGE_MAX + } + ), + github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: issue_number + } + ) + ]); + + core.info(JSON.stringify(files)); /** @type {string[]} */ let labelNames = labels.map((/** @type {{ name: string; }} */ label) => label.name); @@ -268,6 +290,9 @@ export async function summarizeChecksImpl( // /** @type { string | undefined } */ // const changedLabel = context.payload.label?.name; const isDraft = context.payload.pull_request?.draft ?? false; + + // todo: how important is it that we know if we're in draft? I don't want to have to pull the PR details unless we actually need to + // do we need to pull this from the PR? if triggered from a workflow_run we won't have payload.pullrequest populated let labelContext = await updateLabels(targetBranch, isDraft, labelNames); for (const label of labelContext.toRemove) { @@ -434,20 +459,23 @@ function containsNone(arr, values) { return values.every((value) => !arr.includes(value)); } -// /** -// * -// * @param {any[]} arr -// * @param {any[]} values -// * @returns -// */ -// function containsAny(arr, values) { -// return values.some((value) => arr.includes(value)); -// } +/** + * @param {Set} labelsToAdd + * @param {Set} labelsToRemove + */ +function warnIfLabelSetsIntersect(labelsToAdd, labelsToRemove) { + const intersection = Array.from(labelsToAdd).filter(label => labelsToRemove.has(label)); + if (intersection.length > 0) { + console.warn("ASSERTION VIOLATION! The intersection of labelsToRemove and labelsToAdd is non-empty! " + + `labelsToAdd: [${[...labelsToAdd].join(", ")}]. ` + + `labelsToRemove: [${[...labelsToRemove].join(", ")}]. ` + + `intersection: [${intersection.join(", ")}].`) + } +} + // * @param {string} eventName -// * @param {string} targetBranch // * @param {string | undefined } changedLabel - /** * @param {string} targetBranch * @param {boolean} isDraft @@ -467,7 +495,7 @@ export function updateLabels( // it has since been simplified and moved here to handle all label addition and subtraction given a PR context /** @type {import("./labelling.js").LabelContext} */ - const context = { + const labelContext = { present: new Set(existingLabels), toAdd: new Set(), toRemove: new Set() @@ -475,10 +503,63 @@ export function updateLabels( console.log(targetBranch); console.log(isDraft); - // process wait for arm feedback vs arm feedback requested - processArmReviewLabels(context, existingLabels); + processArmReviewLabels(labelContext, existingLabels); + + const { resourceManagerLabelShouldBePresent } = await processPRType( + context, + labelContext + ); + + await processSuppression(context, labelContext); + + const { + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + } = await processBreakingChangeLabels(prContext, labelContext); + + const { rpaasLabelShouldBePresent } = await processRPaaS( + context, + labelContext + ); + + const { newRPNamespaceLabelShouldBePresent } = await processNewRPNamespace( + context, + labelContext, + resourceManagerLabelShouldBePresent + ); + + const { + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent + } = await processNewRpNamespaceWithoutRpaasLabel( + context, + labelContext, + resourceManagerLabelShouldBePresent, + newRPNamespaceLabelShouldBePresent, + rpaasLabelShouldBePresent + ); + + const { ciRpaasRPNotInPrivateRepoLabelShouldBePresent } = await processRpaasRpNotInPrivateRepoLabel( + context, + labelContext, + resourceManagerLabelShouldBePresent, + rpaasLabelShouldBePresent + ); + + const { armReviewLabelShouldBePresent } = await processARMReview( + context, + labelContext, + resourceManagerLabelShouldBePresent, + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent, + isDraft, + ); - return context; + warnIfLabelSetsIntersect(labelContext.toAdd, labelContext.toRemove) + return labelContext; } /** From 5e3e8ea43fe6b1188a2430f9d024f1a9e8db24a9 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 15 Jul 2025 01:09:45 +0000 Subject: [PATCH 11/67] reworking this entire deal. ton of work necessary --- .../src/summarize-checks/labelling.js | 128 --- .../src/summarize-checks/summarize-checks.js | 403 +++++++- .github/workflows/summarize-impact.yaml | 57 ++ eng/tools/package.json | 3 +- eng/tools/summarize-impact/README.md | 20 + .../summarize-impact/cmd/summarize-impact.js | 5 + eng/tools/summarize-impact/package.json | 32 + eng/tools/summarize-impact/src/cli.ts | 119 +++ eng/tools/summarize-impact/src/runner.ts | 945 ++++++++++++++++++ eng/tools/summarize-impact/src/types.ts | 162 +++ eng/tools/summarize-impact/test/cli.test.ts | 31 + eng/tools/summarize-impact/tsconfig.json | 9 + package-lock.json | 26 + 13 files changed, 1759 insertions(+), 181 deletions(-) create mode 100644 .github/workflows/summarize-impact.yaml create mode 100644 eng/tools/summarize-impact/README.md create mode 100755 eng/tools/summarize-impact/cmd/summarize-impact.js create mode 100644 eng/tools/summarize-impact/package.json create mode 100644 eng/tools/summarize-impact/src/cli.ts create mode 100644 eng/tools/summarize-impact/src/runner.ts create mode 100644 eng/tools/summarize-impact/src/types.ts create mode 100644 eng/tools/summarize-impact/test/cli.test.ts create mode 100644 eng/tools/summarize-impact/tsconfig.json diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index 6ce4488f04fd..b92334578992 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -380,134 +380,6 @@ export const breakingChangesCheckType = { // #endregion constants // #region LabelContext -/** - * @param {string} swaggerFilePath - * @returns {boolean} - */ -function isSwaggerGeneratedByTypeSpec(swaggerFilePath) { - try { - return !!JSON.parse(readFileSync(swaggerFilePath).toString())?.info['x-typespec-generated'] - } - catch { - return false - } -} - -/** - * @param {any} ctx - * @param {LabelContext} labelContext - * @returns {Promise} - */ -async function processTypeSpec(ctx, labelContext) { - console.log("ENTER definition processTypeSpec") - const typeSpecLabel = new Label("TypeSpec", labelContext.present); - // By default this label should not be present. We may determine later in this function that it should be present after all. - typeSpecLabel.shouldBePresent = false; - /** @type {ChangeHandler[]} */ - const handlers = []; - const typeSpecFileHandler = () => { - /** - * @param {PRChange} prChange - */ - return (prChange) => { - // Note: this code will be executed if the PR has a diff on a TypeSpec file, - // as defined in public/swagger-validation-common/src/context.ts/defaultFilePatterns/typespec - typeSpecLabel.shouldBePresent = true; - }; - }; - const swaggerFileHandler = () => { - /** - * @param {PRChange} prChange - */ - return (prChange) => { - if (prChange.changeType !== "Deletion" && isSwaggerGeneratedByTypeSpec(prChange.filePath)) { - typeSpecLabel.shouldBePresent = true; - } - }; - }; - handlers.push({ - TypeSpecFile: typeSpecFileHandler(), - SwaggerFile: swaggerFileHandler(), - }); - await processPrChanges(ctx, handlers); - - typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processTypeSpec") -} -export async function processPrChanges( - ctx: IValidatorContext, - Handlers: ChangeHandler[] -) { - console.log("ENTER definition processPrChanges") - const prChange = await getPRChanges(ctx); - prChange.forEach((prChange) => { - Handlers.forEach((handler) => { - if (prChange.fileType in handler) { - handler?.[prChange.fileType]?.(prChange); - } - }); - }); - console.log("RETURN definition processPrChanges") -} - -export async function getPRChanges(ctx: IValidatorContext): Promise { - console.log("ENTER definition getPRChanges") - const results: PRChange[] = []; - - function newChange( - fileType: FileTypes, - changeType: ChangeTypes, - filePath?: string, - additionalInfo?: any - ) { - if (filePath) { - results.push({ - filePath, - fileType, - changeType, - additionalInfo, - }); - } - } - - function newChanges( - fileType: FileTypes, - changeType: ChangeTypes, - files?: string[] - ) { - if (files) { - files.forEach((filePath) => newChange(fileType, changeType, filePath)); - } - } - - function genChanges(type: FileTypes, diffs: DiffResult) { - newChanges(type, "Addition", diffs.additions); - newChanges(type, "Deletion", diffs.deletions); - newChanges(type, "Update", diffs.changes); - } - - function genReadmeChanges(readmeDiffs?: DiffResult) { - if (readmeDiffs) { - readmeDiffs.additions?.forEach((d) => - newChange("ReadmeFile", "Addition", d.readme, d.tags) - ); - readmeDiffs.changes?.forEach((d) => - newChange("ReadmeFile", "Update", d.readme, d.tags) - ); - readmeDiffs.deletions?.forEach((d) => - newChange("ReadmeFile", "Deletion", d.readme, d.tags) - ); - } - } - - genChanges("SwaggerFile", await ctx.getSwaggerDiffs()); - genChanges("TypeSpecFile", await ctx.getTypeSpecDiffs()); - genChanges("ExampleFile", await ctx.getExampleDiffs()); - genReadmeChanges(await ctx.getReadmeDiffs()); - - console.log("RETURN definition getPRChanges") - return results; -} async function processARMReview( context: IValidatorContext, diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 04a15b69201a..170685d629b6 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -503,60 +503,21 @@ export function updateLabels( console.log(targetBranch); console.log(isDraft); + // this is the only labelling that was part of original pipelinebot logic processArmReviewLabels(labelContext, existingLabels); - const { resourceManagerLabelShouldBePresent } = await processPRType( - context, - labelContext - ); - - await processSuppression(context, labelContext); - - const { - versioningReviewRequiredLabelShouldBePresent, - breakingChangeReviewRequiredLabelShouldBePresent, - } = await processBreakingChangeLabels(prContext, labelContext); - - const { rpaasLabelShouldBePresent } = await processRPaaS( - context, - labelContext - ); - - const { newRPNamespaceLabelShouldBePresent } = await processNewRPNamespace( - context, - labelContext, - resourceManagerLabelShouldBePresent - ); - - const { - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, - rpaasExceptionLabelShouldBePresent - } = await processNewRpNamespaceWithoutRpaasLabel( - context, - labelContext, - resourceManagerLabelShouldBePresent, - newRPNamespaceLabelShouldBePresent, - rpaasLabelShouldBePresent - ); - - const { ciRpaasRPNotInPrivateRepoLabelShouldBePresent } = await processRpaasRpNotInPrivateRepoLabel( - context, - labelContext, - resourceManagerLabelShouldBePresent, - rpaasLabelShouldBePresent - ); - - const { armReviewLabelShouldBePresent } = await processARMReview( - context, - labelContext, - resourceManagerLabelShouldBePresent, - versioningReviewRequiredLabelShouldBePresent, - breakingChangeReviewRequiredLabelShouldBePresent, - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, - rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent, - isDraft, - ); + // // this one SHOULD remain here. It has a lot of the logic around handling ARM review labels + // const { armReviewLabelShouldBePresent } = await processARMReview( + // context, + // labelContext, + // resourceManagerLabelShouldBePresent, + // versioningReviewRequiredLabelShouldBePresent, + // breakingChangeReviewRequiredLabelShouldBePresent, + // ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + // rpaasExceptionLabelShouldBePresent, + // ciRpaasRPNotInPrivateRepoLabelShouldBePresent, + // isDraft, + // ); warnIfLabelSetsIntersect(labelContext.toAdd, labelContext.toRemove) return labelContext; @@ -599,7 +560,345 @@ export function processArmReviewLabels( } } +/** +This function determines which labels of the ARM review should +be applied to given PR. It adds and removes the labels as appropriate. + +This function does the following, **among other things**: + +- Adds the "ARMReview" label if all of the following conditions hold: + - The processed PR "isReleaseBranch" or "isShiftLeftPRWithRPSaaSDev" + - The PR is not a draft, as determined by "isDraftPR" + - The PR is labelled with "resource-manager" label, meaning it pertains + to ARM, as previously determined by the "isManagementPR" function, + called from the "getPRType" function. + +- Calls the "processARMReviewWorkflowLabels" function if "ARMReview" label applies. +*/ +// todo: refactor to take context: PRContext as input instead of IValidatorContext. +// All downstream usage appears to be using "context.contextConfig() as PRContext". +async function processARMReview( + context: IValidatorContext, + owner: string, + repo: string, + issue_number: number, + labelContext: LabelContext, + resourceManagerLabelShouldBePresent: boolean, + versioningReviewRequiredLabelShouldBePresent: boolean, + breakingChangeReviewRequiredLabelShouldBePresent: boolean, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, + rpaasExceptionLabelShouldBePresent: boolean, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean, + isDraft: boolean, +): Promise<{ armReviewLabelShouldBePresent: boolean }> { + console.log("ENTER definition processARMReview") + + const armReviewLabel = new Label("ARMReview", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + armReviewLabel.shouldBePresent = false; + + const newApiVersionLabel = new Label("new-api-version", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + newApiVersionLabel.shouldBePresent = false; + + const branch = (context.contextConfig() as PRContext).targetBranch + const prTitle = await getPrTitle(owner, repo, prNumber) + const isReleaseBranchVal: boolean = isReleaseBranch(branch) + const isShiftLeftPRWithRPSaaSDevVal: boolean = isShiftLeftPRWithRPSaaSDev(prTitle, branch) + const isBranchInScopeOfSpecReview: boolean = isReleaseBranchVal || isShiftLeftPRWithRPSaaSDevVal + let isNewApiVersionVal: boolean | "not_computed" = "not_computed" + let isMissingBaseCommit: boolean | "not_computed" = "not_computed" + + // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic + // determines which kind of review exactly we need. + let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview + if (specReviewApplies) { + isNewApiVersionVal = await isNewApiVersion(context) + if (isNewApiVersionVal) { + // Note that in case of data-plane PRs, the addition of this label will result + // in API stewardship board review being required. + // See requiredLabelsRules.ts. + newApiVersionLabel.shouldBePresent = true; + } + + armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent + await processARMReviewWorkflowLabels( + labelContext, + armReviewLabel.shouldBePresent, + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent) + } + + newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) + armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) + + console.log(`RETURN definition processARMReview. ` + + `url: ${new PRKey(owner, repo, prNumber).toUrl()}, owner: ${owner}, repo: ${repo}, pr: ${prNumber}, branch: ${branch}, ` + + `isReleaseBranch: ${isReleaseBranchVal}, ` + + `isShiftLeftPRWithRPSaaSDev: ${isShiftLeftPRWithRPSaaSDevVal}, ` + + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` + + `isNewApiVersion: ${isNewApiVersionVal}, ` + + `isMissingBaseCommit: ${isMissingBaseCommit}, ` + + `isDraft: ${isDraft}, ` + + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` + + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) + + return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } +} +function isReleaseBranch(branchName: string) { + const branchRegex = [/main/, /RPSaaSMaster/, /release*/, /ARMCoreRPDev/]; + return branchRegex.some((b) => b.test(branchName)); +} + +// For shift-left review process, if the PR target branch is RPSaaSDev, it still need ARM review +function isShiftLeftPRWithRPSaaSDev( + prTitle: string, + branch: string +): boolean { + const shiftLeftPRTitle = "[AutoSync]"; + const isShiftLeftPR = prTitle.includes(shiftLeftPRTitle) && branch === "RPSaaSDev"; + if (isShiftLeftPR) { + console.log( + `The PR is shift-left PR with RPSaaSDev branch. it need ARM review` + ); + } + return isShiftLeftPR; +} + +async function isNewApiVersion(context: IValidatorContext): Promise { + const pr = await createPullRequestProperties( + context, + "pr-summary-new-api-version" + ); + const handlers: ChangeHandler[] = []; + let isAddingNewApiVersion = false; + const apiVersionSet = new Set(); + + const rpFolders = new Set(); + + const createSwaggerFileHandler = () => { + return (e: PRChange) => { + if (e.changeType === "Addition") { + const apiVersion = getApiVersionFromSwaggerFile(e.filePath); + if (apiVersion) { + apiVersionSet.add(apiVersion); + } + const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + if (rpFolder !== undefined) { + rpFolders.add(rpFolder); + } + console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); + } else if (e.changeType === "Update") { + const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + if (rpFolder !== undefined) { + rpFolders.add(rpFolder); + } + } + }; + }; + + handlers.push({ SwaggerFile: createSwaggerFileHandler() }); + await processPrChanges(context, handlers); + + console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); + + const firstRPFolder = Array.from(rpFolders)[0]; + + console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); + + if (firstRPFolder === undefined) { + console.log("RP folder not found."); + return false; + } + + const targetBranchRPFolder = resolve(pr?.workingDir!, firstRPFolder); + + console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + + const existingApiVersions = + getAllApiVersionFromRPFolder(targetBranchRPFolder); + + console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + + for (const apiVersion of apiVersionSet) { + if (!existingApiVersions.includes(apiVersion)) { + console.log( + `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` + ); + isAddingNewApiVersion = true; + } + } + return isAddingNewApiVersion; +} + +/** +CODESYNC: +- requiredLabelsRules.ts / requiredLabelsRules +- https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml + +This function determines which label from the ARM review workflow labels +should be present on the PR. It adds and removes the labels as appropriate. + +In other words, this function captures the +ARM review workflow label processing logic. + +To be exact, this function executes if and only if the PR in question +has been determined to have the "ARMReview" label, denoting given PR +is in scope for ARM review. + +The implementation of this function is the source of truth specifying the +desired behavior. + +To understand this implementation, the most important constraint to keep in mind +is that if "ARMReview" label is present, then exactly one of the following +labels must be present: + +- NotReadyForARMReview +- WaitForARMFeedback +- ARMChangesRequested +- ARMSignedOff + +Note that another important place in this codebase where ARM review workflow +labels are being removed or added to a PR is pipelineBotOnPRLabelEvent.ts. +*/ +async function processARMReviewWorkflowLabels( + labelContext: LabelContext, + armReviewLabelShouldBePresent: boolean, + versioningReviewRequiredLabelShouldBePresent: boolean, + breakingChangeReviewRequiredLabelShouldBePresent: boolean, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, + rpaasExceptionLabelShouldBePresent: boolean, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean +): Promise { + console.log("ENTER definition processARMReviewWorkflowLabels"); + + const notReadyForArmReviewLabel = new Label( + "NotReadyForARMReview", + labelContext.present); + + const waitForArmFeedbackLabel = new Label( + "WaitForARMFeedback", + labelContext.present + ); + + const armChangesRequestedLabel = new Label( + "ARMChangesRequested", + labelContext.present + ); + + const armSignedOffLabel = new Label("ARMSignedOff", labelContext.present); + + const blockedOnVersioningPolicy = getBlockedOnVersioningPolicy( + labelContext, + breakingChangeReviewRequiredLabelShouldBePresent, + versioningReviewRequiredLabelShouldBePresent + ); + + const blockedOnRpaas = getBlockedOnRpaas( + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent + ); + + const blocked = blockedOnVersioningPolicy || blockedOnRpaas; + + // If given PR is in scope of ARM review and it is blocked for any reason, + // the "NotReadyForARMReview" label should be present, to the exclusion + // of all other ARM review workflow labels. + notReadyForArmReviewLabel.shouldBePresent = + armReviewLabelShouldBePresent && blocked; + + // If given PR is in scope of ARM review and the review is not blocked, + // then "ARMSignedOff" label should remain present on the PR if it was + // already present. This means that labels "ARMChangesRequested" + // and "WaitForARMFeedback" are invalid and will be removed by automation + // in presence of "ARMSignedOff". + armSignedOffLabel.shouldBePresent = + armReviewLabelShouldBePresent && + !blocked && + armSignedOffLabel.present; + + // If given PR is in scope of ARM review and the review is not blocked and + // not signed-off, then the label "ARMChangesRequested" should remain present + // if it was already present. This means that labels "WaitForARMFeedback" + // is invalid and will be removed by automation in presence of + // "WaitForARMFeedback". + armChangesRequestedLabel.shouldBePresent = + armReviewLabelShouldBePresent && + !blocked && + !armSignedOffLabel.shouldBePresent && + armChangesRequestedLabel.present; + + // If given PR is in scope of ARM review and the review is not blocked and + // not signed-off, and ARM reviewer didn't request any changes, + // then the label "WaitForARMFeedback" should be present on the PR, whether + // it was present before or not. + waitForArmFeedbackLabel.shouldBePresent = + armReviewLabelShouldBePresent && + !blocked && + !armSignedOffLabel.shouldBePresent && + !armChangesRequestedLabel.shouldBePresent && + (waitForArmFeedbackLabel.present || true); + + const exactlyOneArmReviewWorkflowLabelShouldBePresent = + (Number(notReadyForArmReviewLabel.shouldBePresent) + + Number(armSignedOffLabel.shouldBePresent) + + Number(armChangesRequestedLabel.shouldBePresent) + + Number(waitForArmFeedbackLabel.shouldBePresent) === + 1) || !armReviewLabelShouldBePresent + + if (!exactlyOneArmReviewWorkflowLabelShouldBePresent) { + console.warn( + "ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false" + ); + } + + notReadyForArmReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + armSignedOffLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + armChangesRequestedLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + waitForArmFeedbackLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + + console.log( + `RETURN definition processARMReviewWorkflowLabels. ` + + `presentLabels: ${[...labelContext.present].join(",")}, ` + + `blockedOnVersioningPolicy: ${blockedOnVersioningPolicy}. ` + + `blockedOnRpaas: ${blockedOnRpaas}. ` + + `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. ` + ); + return; +} + +function getBlockedOnVersioningPolicy( + labelContext: LabelContext, + breakingChangeReviewRequiredLabelShouldBePresent: boolean, + versioningReviewRequiredLabelShouldBePresent: boolean +) { + const pendingVersioningReview = + versioningReviewRequiredLabelShouldBePresent && + !anyApprovalLabelPresent("SameVersion", [...labelContext.present]); + + const pendingBreakingChangeReview = + breakingChangeReviewRequiredLabelShouldBePresent && + !anyApprovalLabelPresent("CrossVersion", [...labelContext.present]); + + const blockedOnVersioningPolicy = + pendingVersioningReview || pendingBreakingChangeReview + return blockedOnVersioningPolicy; +} + +function getBlockedOnRpaas( + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, + rpaasExceptionLabelShouldBePresent: boolean, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean +) +{ + return (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) + || ciRpaasRPNotInPrivateRepoLabelShouldBePresent +} // #endregion // #region checks /** diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml new file mode 100644 index 000000000000..307f70b98224 --- /dev/null +++ b/.github/workflows/summarize-impact.yaml @@ -0,0 +1,57 @@ +name: "Summarize PR Impact" + +on: pull_request + +permissions: + contents: read + +jobs: + lintdiff: + name: "Summarize PR Impact" + runs-on: ubuntu-24.04 + + steps: + - name: Checkout eng + uses: actions/checkout@v4 + with: + sparse-checkout: | + eng/ + .github/ + + - name: Checkout 'after' state + uses: actions/checkout@v4 + with: + fetch-depth: 2 + path: after + + - name: Checkout 'before' state + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: before + + - name: Setup Node and install deps + uses: ./.github/actions/setup-node-install-deps + + - name: Run Summarize Impact + id: summarize-impact + run: | + npm exec --no -- summarize-impact -d after \ + --number "${{ github.event.pull_request.number }}" \ + --sb "${{ github.event.pull_request.head.ref }}" \ + --tb "${{ github.event.pull_request.base.ref }}" \ + --sha "${{ github.event.pull_request.head.sha }}" \ + --repo "${{ github.repository }}" + env: + # We absolutely need to avoid OOM errors due to certain inherited types from openapi-alps + NODE_OPTIONS: "--max-old-space-size=8192" + + # Used by other workflows like set-status + - name: Set job-summary artifact + if: ${{ always() && steps.summarize-impact.outputs.summary }} + uses: actions/upload-artifact@v4 + with: + name: job-summary + path: ${{ steps.summarize-impact.outputs.summary }} + # If the file doesn't exist, just don't add the artifact + if-no-files-found: ignore diff --git a/eng/tools/package.json b/eng/tools/package.json index 981da6b3bb5e..e7bceb3ca520 100644 --- a/eng/tools/package.json +++ b/eng/tools/package.json @@ -10,7 +10,8 @@ "@azure-tools/tsp-client-tests": "file:tsp-client-tests", "@azure-tools/typespec-migration-validation": "file:typespec-migration-validation", "@azure-tools/typespec-requirement": "file:typespec-requirement", - "@azure-tools/typespec-validation": "file:typespec-validation" + "@azure-tools/typespec-validation": "file:typespec-validation", + "@azure-tools/summarize-impact": "file:summarize-impact" }, "scripts": { "build": "tsc --build", diff --git a/eng/tools/summarize-impact/README.md b/eng/tools/summarize-impact/README.md new file mode 100644 index 000000000000..6c4b1bcbfba6 --- /dev/null +++ b/eng/tools/summarize-impact/README.md @@ -0,0 +1,20 @@ +# `Summarize Impact` + +This tool models the PR and produces an artifact that is consumed by the `summarize-checks` check. + +The schema of the artifact looks like: + +```json +{ + "pr-type": ["resource-manager", "data-plane"], + "suppressionsChanged": true, + "versioningReviewRequired": false, + "breakingChangeReviewRequired": true, + "rpaasChange": true, + "newRP": true, + "rpaasRPMissing": true +} +``` + +## Invocation + diff --git a/eng/tools/summarize-impact/cmd/summarize-impact.js b/eng/tools/summarize-impact/cmd/summarize-impact.js new file mode 100755 index 000000000000..abdd7016b6cf --- /dev/null +++ b/eng/tools/summarize-impact/cmd/summarize-impact.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from "../dist/src/cli.js"; + +await main(); diff --git a/eng/tools/summarize-impact/package.json b/eng/tools/summarize-impact/package.json new file mode 100644 index 000000000000..fd5fe69aa39f --- /dev/null +++ b/eng/tools/summarize-impact/package.json @@ -0,0 +1,32 @@ +{ + "name": "@azure-tools/summarize-impact", + "private": true, + "type": "module", + "main": "dist/src/main.js", + "bin": { + "summarize-impact": "cmd/summarize-impact.js" + }, + "scripts": { + "build": "tsc --build", + "format": "prettier . --ignore-path ../.prettierignore --write", + "format:check": "prettier . --ignore-path ../.prettierignore --check", + "format:check:ci": "prettier . --ignore-path ../.prettierignore --check --log-level debug", + "test": "vitest", + "test:ci": "vitest run --coverage --reporter=verbose" + }, + "dependencies": { + "@azure-tools/specs-shared": "file:../../../.github/shared", + "glob": "latest", + "js-yaml": "^4.1.0", + "simple-git": "^3.27.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "prettier": "~3.5.3", + "typescript": "~5.8.2", + "vitest": "^3.0.7" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts new file mode 100644 index 000000000000..278206bf41f6 --- /dev/null +++ b/eng/tools/summarize-impact/src/cli.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import { evaluateImpact } from "./runner.js"; +import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; + +import { resolve } from "path"; +import { parseArgs, ParseArgsConfig } from "node:util"; +import fs from "node:fs/promises"; +import { simpleGit } from "simple-git"; +import { extractInputs } from "@azure-tools/specs-shared/context"; + +export async function getRootFolder(inputPath: string): Promise { + try { + const gitRoot = await simpleGit(inputPath).revparse("--show-toplevel"); + return resolve(gitRoot.trim()); + } catch (error) { + console.error( + `Error: Unable to determine the root folder of the git repository.`, + `Please ensure you are running this command within a git repository OR providing a targeted directory that is within a git repo.`, + ); + process.exit(1); + } +} + +export async function main() { + const config: ParseArgsConfig = { + options: { + targetDirectory: { + type: "string", + short: "d", + multiple: false, + default: process.cwd(), + }, + fileList: { + type: "string", + short: "f", + multiple: false, + default: undefined, + }, + number: { + type: "string", + short: "n", + multiple: false + }, + sourceBranch: { + type: "string", + short: "sb", + multiple: false, + }, + targetBranch: { + type: "string", + short: "tb", + multiple: false, + }, + sha: { + type: "string", + short: "s", + multiple: false, + }, + repo: { + type: "string", + short: "r", + multiple: false, + } + }, + allowPositionals: true, + }; + + const { values: opts, positionals } = parseArgs(config); + console.log(positionals); + // this option has a default value of process.cwd(), so we can assume it is always defined + // just need to resolve that here to make ts aware of it + const targetDirectory = opts.targetDirectory as string; + + const resolvedGitRoot = await getRootFolder(targetDirectory); + + let fileList: string[] | undefined = undefined; + if (opts.fileList !== undefined) { + const fileListPath = resolve(opts.fileList as string); + try { + const fileContent = await fs.readFile(fileListPath, { encoding: "utf-8" }); + fileList = fileContent + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + console.log(`Loaded ${fileList.length} files from ${opts.fileList}`); + } catch (error) { + console.error( + `Error reading file list from ${opts.fileList}: ${error instanceof Error ? error.message : String(error)}`, + ); + console.error("User provided file list that is not found."); + console.error( + "Please ensure the file exists and is readable, or do not provide the option 'fileList'", + ); + process.exit(1); + } + } + else { + if (!fileList){ + await getChangedFiles({ cwd: resolvedGitRoot }); + } + } + + const sha = opts.sha as string; + const sourceBranch = opts.sourceBranch as string; + const targetBranch = opts.targetBranch as string; + const repo = opts.repo as string; + const prNumber = opts.number as string; + + const context: any = { + sha, + sourceBranch, + targetBranch, + repo, + prNumber + }; + + evaluateImpact(context, resolvedGitRoot, fileList,); +} diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts new file mode 100644 index 000000000000..3523d0d36dd1 --- /dev/null +++ b/eng/tools/summarize-impact/src/runner.ts @@ -0,0 +1,945 @@ +#!/usr/bin/env node + +// import * as path from "path"; +import * as fs from "fs"; +// import { glob } from "glob"; + + +// import { Swagger } from "@azure-tools/specs-shared/swagger"; +// import { includesFolder } from "@azure-tools/specs-shared/path"; +// import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; //getChangedFiles, + +import { FileTypes, ChangeTypes, PRChange, ChangeHandler, PRType, ImpactAssessment, LabelContext } from "./types.js" + +export async function evaluateImpact(context: PRContext, targetDirectory: string, existingLabels: string[], fileList?: string[]): Promise { + const labelContext: LabelContext = { + present: new Set(existingLabels), + toAdd: new Set(), + toRemove: new Set() + }; + + // examine changed files. if changedpaths includes data-plane, add "data-plane" + // same for "resource-manager". We care about whether resourcemanager will be present for a later check + const { resourceManagerLabelShouldBePresent } = await processPRType( + context, + labelContext + ); + + // Has to be run in a PR context. Uses addition and update to understand + // if the suppressions have been changed. If they have, suppressionReviewRequired must be added + // as a label + await processSuppression(context, labelContext); + + // Has to run in PR context. + // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present + const { + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + } = await processBreakingChangeLabels(prContext, labelContext); + + // needs to examine "after" context to understand if a readme that was changed is RPaaS or not + const { rpaasLabelShouldBePresent } = await processRPaaS( + context, + labelContext + ); + + // Has to be in PR context. Uses the addition to understand if the newRPNamespace label should be present. + const { newRPNamespaceLabelShouldBePresent } = await processNewRPNamespace( + context, + labelContext, + resourceManagerLabelShouldBePresent + ); + + // doesn't necessarily need to be in the PR context. + // Uses the previous outputs that DID need to be a PR context, but otherwise only examines targetBranch and those + // output labels. + const { + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent + } = await processNewRpNamespaceWithoutRpaasLabel( + context, + labelContext, + resourceManagerLabelShouldBePresent, + newRPNamespaceLabelShouldBePresent, + rpaasLabelShouldBePresent + ); + + // examines the additions. if the changetype is addition, then it will add the ciRpaasRPNotInPrivateRepo label + const { ciRpaasRPNotInPrivateRepoLabelShouldBePresent } = await processRpaasRpNotInPrivateRepoLabel( + context, + labelContext, + resourceManagerLabelShouldBePresent, + rpaasLabelShouldBePresent + ); + + return { + prType: [], + suppressionsChanged: labelContext.toAdd.has("suppressionsReviewRequired"), + versioningReviewRequired: versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequired: breakingChangeReviewRequiredLabelShouldBePresent, + rpaasChange: rpaasLabelShouldBePresent, + newRP: newRPNamespaceLabelShouldBePresent, + rpaasRPMissing: ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent || rpaasExceptionLabelShouldBePresent + }; +} + +export function isManagementPR(filePaths: string[]): boolean { + return filePaths.some((it) => it.includes("resource-manager")); +} + +export function isDataPlanePR(filePaths: string[]): boolean { + return filePaths.some((it) => it.includes("data-plane")); +} + + +export function getAllApiVersionFromRPFolder(rpFolder: string): string[] { + const allSwaggerFilesFromRPFolder = glob.sync(`${rpFolder}/**/*.json`); + console.log(`allSwaggerFilesFromRPFolder: ${allSwaggerFilesFromRPFolder}`); + + const apiVersions: Set = new Set(); + for (const it of allSwaggerFilesFromRPFolder) { + if (!it.includes("examples")) { + const apiVersion = getApiVersionFromSwaggerFile(it); + console.log( + `Get api version from swagger file: ${it}, apiVersion: ${apiVersion}` + ); + if (apiVersion) { + apiVersions.add(apiVersion); + } + } + } + return [...apiVersions]; +} + +export function getApiVersionFromSwaggerFile( + swaggerFile: string +): string | undefined { + const swagger = fs.readFileSync(swaggerFile).toString(); + const swaggerObject = JSON.parse(swagger); + if (swaggerObject["info"] && swaggerObject["info"]["version"]) { + return swaggerObject["info"]["version"]; + } + return undefined; +} + +export function getRPFolderFromSwaggerFile( + swaggerFile: string +): string | undefined { + const resourceProvider = getResourceProviderFromFilePath(swaggerFile); + + if (resourceProvider === undefined) { + return undefined; + } + + const lastIdx = swaggerFile.lastIndexOf(resourceProvider!); + return swaggerFile.substring(0, lastIdx + resourceProvider!.length); +} + +export const getResourceProviderFromFilePath = ( + filePath: string +): string | undefined => { + + // Example filePath: + // specification/purview/data-plane/Azure.Analytics.Purview.Workflow/preview/2023-10-01-preview/purviewWorkflow.json + // Match: + // /Azure.Analytics.Purview.Workflow/ + // Note: + // The regex matches a directory name in the path of form: /Foo.Bar.Baz/, where: + // - The directory name must have at least one period. If it would match 0 periods, + // then in the example path it would match to "/purview/" instead. + // - The directory name can have one or more periods. + // The "Foo.Bar.Baz" example given above has two periods. + const regex = /\/([a-z0-9]+(\.[a-z0-9]+)+)\//i; + const match = filePath.match(regex); + if (match && match.length > 0) { + return match[1]; + } + // Second matching attempt to cover scenarios in which: + // - the resource provider is for data-plane + // - and it has no dots in the name. + // + // Example filePath: + // specification/communication/data-plane/Sms/preview/2024-02-05-preview/communicationServicesSms.json + // Match: + // /Sms/ + // + // For details see: https://github.com/Azure/azure-sdk-tools/issues/7552 + const regexForDirImmediatelyAfterDataPlane = /\/data-plane\/([a-z0-9]+)\//i; + const match2 = filePath.match(regexForDirImmediatelyAfterDataPlane); + if (match2 && match2.length > 0) { + return match2[1]; + } + + return undefined; +}; + +async function isNewApiVersion(context: IValidatorContext): Promise { + const pr = await createPullRequestProperties( + context, + "pr-summary-new-api-version" + ); + const handlers: ChangeHandler[] = []; + let isAddingNewApiVersion = false; + const apiVersionSet = new Set(); + + const rpFolders = new Set(); + + const createSwaggerFileHandler = () => { + return (e: PRChange) => { + if (e.changeType === "Addition") { + const apiVersion = getApiVersionFromSwaggerFile(e.filePath); + if (apiVersion) { + apiVersionSet.add(apiVersion); + } + const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + if (rpFolder !== undefined) { + rpFolders.add(rpFolder); + } + console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); + } else if (e.changeType === "Update") { + const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + if (rpFolder !== undefined) { + rpFolders.add(rpFolder); + } + } + }; + }; + + handlers.push({ SwaggerFile: createSwaggerFileHandler() }); + await processPrChanges(context, handlers); + + console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); + + const firstRPFolder = Array.from(rpFolders)[0]; + + console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); + + if (firstRPFolder === undefined) { + console.log("RP folder not found."); + return false; + } + + const targetBranchRPFolder = resolve(pr?.workingDir!, firstRPFolder); + + console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + + const existingApiVersions = + getAllApiVersionFromRPFolder(targetBranchRPFolder); + + console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + + for (const apiVersion of apiVersionSet) { + if (!existingApiVersions.includes(apiVersion)) { + console.log( + `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` + ); + isAddingNewApiVersion = true; + } + } + return isAddingNewApiVersion; +} + +async function processBreakingChangeLabels( + prContext: PRContext, + labelContext: LabelContext): Promise<{ + versioningReviewRequiredLabelShouldBePresent: boolean, + breakingChangeReviewRequiredLabelShouldBePresent: boolean, + }>{ + + const prTargetsProductionBranch: boolean = checkPrTargetsProductionBranch(prContext); + const prKey: PRKey = PRKey.fromSourceRepoUri(prContext.sourceRepoUri, prContext.prNumber); + + const breakingChangesLabelsFromOad = getBreakingChangesLabelsFromOad(); + + const versioningReviewRequiredLabel = new Label( + breakingChangesCheckType.SameVersion.reviewRequiredLabel, + labelContext.present + ); + + const breakingChangeReviewRequiredLabel = new Label( + breakingChangesCheckType.CrossVersion.reviewRequiredLabel, + labelContext.present + ); + + const versioningApprovalLabels = breakingChangesCheckType.SameVersion.approvalLabels.map(label => new Label(label, labelContext.present)); + const breakingChangeApprovalLabels = breakingChangesCheckType.CrossVersion.approvalLabels.map(label => new Label(label, labelContext.present)); + + // ----- Breaking changes label processing logic ----- + // These lines implement the set of rules determining which of the breaking change + // labels should be present on given PR. + // The doc describing breaking change review rules can be found here: + // https://aka.ms/brch-dev + + // ❗ IMPORTANT ❗: this MUST be set BEFORE versioningReviewRequiredLabel.shouldBePresent + // due to logical dependency + breakingChangeReviewRequiredLabel.shouldBePresent = + // "BreakingChangeReviewRequired" label should be present if + // 1. The PR targets a production branch + prTargetsProductionBranch + // 2. AND given OAD run determined it is still applicable. + && breakingChangesLabelsFromOad.includes(breakingChangeReviewRequiredLabel.name) + + // ❗ IMPORTANT ❗: this MUST be set AFTER breakingChangeReviewRequiredLabel.shouldBePresent + // due to logical dependency + versioningReviewRequiredLabel.shouldBePresent = + // "VersioningReviewRequired" label should be unconditionally removed if + // the label "BreakingChangeReviewRequired" should be present. + !breakingChangeReviewRequiredLabel.shouldBePresent + // AND "VersioningReviewRequired" label should be present if + // 1. The PR targets a production branch + && prTargetsProductionBranch + // 2. AND given OAD run determined it is still applicable. + && breakingChangesLabelsFromOad.includes(versioningReviewRequiredLabel.name); + + versioningApprovalLabels.forEach(approvalLabel => { + approvalLabel.shouldBePresent = approvalLabel.present && versioningReviewRequiredLabel.shouldBePresent; + }); + + breakingChangeApprovalLabels.forEach(approvalLabel => { + approvalLabel.shouldBePresent = approvalLabel.present && breakingChangeReviewRequiredLabel.shouldBePresent; + }); + + // ---------- + + applyLabelsStateChanges(labelContext.toAdd, labelContext.toRemove); + + logDiagInfo(); + + return { + versioningReviewRequiredLabelShouldBePresent: versioningReviewRequiredLabel.shouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent: breakingChangeReviewRequiredLabel.shouldBePresent + }; + + /** + * Get labels denoting required breaking change or versioning review. + * The labels are read from ADO pipeline environment variables. + * These variables are set to appropriate value by BreakingChangesRuleManager.addBreakingChangeLabels(). + * Read that function's comment for details. + */ + function getBreakingChangesLabelsFromOad(): string[] { + const oadLabelsEnvVars = [ + breakingChangeLabelVarName.toUpperCase(), + crossVersionBreakingChangeLabelVarName.toUpperCase(), + ]; + let breakingChangeLabelsFromOad: string[] = _.uniq( + oadLabelsEnvVars + .map((labelSenderEnvVar) => process.env[labelSenderEnvVar]) + .filter((envVarValue) => envVarValue !== undefined) + .map((envVarValue) => envVarValue?.split(",") || []) + .reduce((accumulatedLabels, currentEnvVarLabels) => accumulatedLabels.concat(currentEnvVarLabels), []) + ).filter((label) => label); + return breakingChangeLabelsFromOad; + } + + function applyLabelsStateChanges(labelsToAdd: Set, labelsToRemove: Set) { + breakingChangeReviewRequiredLabel.applyStateChange(labelsToAdd, labelsToRemove); + versioningReviewRequiredLabel.applyStateChange(labelsToAdd, labelsToRemove); + versioningApprovalLabels.forEach(approvalLabel => { + approvalLabel.applyStateChange(labelsToAdd, labelsToRemove); + }); + breakingChangeApprovalLabels.forEach(approvalLabel => { + approvalLabel.applyStateChange(labelsToAdd, labelsToRemove); + }); + } + + function logDiagInfo() { + if (!prTargetsProductionBranch + && ( + breakingChangesLabelsFromOad.includes(breakingChangeReviewRequiredLabel.name) + || breakingChangesLabelsFromOad.includes(versioningReviewRequiredLabel.name) + )) { + // We are using this log as a metric to track and measure impact of the work on improving "breaking changes" tooling. Log statement added around 11/29/2023. + // See: https://github.com/Azure/azure-sdk-tools/issues/7223#issuecomment-1839830834 + // Note it duplicates the label "shouldBePresent" ruleset logic. + console.log(`processBreakingChangeLabels: PR: ${prKey.toUrl()}, targetBranch: ${prContext.targetBranch}. ` + + `The addition of 'BreakingChangesReviewRequired' or 'VersioningReviewRequired' labels has been prevented ` + + `because we checked that the PR is not targeting a production branch.`); + } + + console.log(`processBreakingChangeLabels returned. ` + + `prTargetsProductionBranch: ${prTargetsProductionBranch}, ` + + breakingChangeReviewRequiredLabel.logString() + + versioningReviewRequiredLabel.logString() + ); + } +} + +/** + * The "production" branches are defined at https://aka.ms/azsdk/pr-brch-deep + */ +function checkPrTargetsProductionBranch(prContext: PRContext): boolean { + + const targetsPublicProductionBranch = + prContext.sourceRepoUri.includes("azure-rest-api-specs") + && !prContext.sourceRepoUri.includes("azure-rest-api-specs-pr") + && prContext.targetBranch == "main" + + const targetsPrivateProductionBranch = + prContext.sourceRepoUri.includes("azure-rest-api-specs-pr") + && prContext.targetBranch == "RPSaaSMaster" + + return targetsPublicProductionBranch || targetsPrivateProductionBranch +} + +\async function processTypeSpec( + ctx: IValidatorContext, + labelContext: LabelContext, +) { + console.log("ENTER definition processTypeSpec") + const typeSpecLabel = new Label("TypeSpec", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + typeSpecLabel.shouldBePresent = false; + const handlers: ChangeHandler[] = []; + const typeSpecFileHandler = () => { + return (prChange: PRChange) => { + // Note: this code will be executed if the PR has a diff on a TypeSpec file, + // as defined in public/swagger-validation-common/src/context.ts/defaultFilePatterns/typespec + typeSpecLabel.shouldBePresent = true; + }; + }; + const swaggerFileHandler = () => { + return (prChange: PRChange) => { + if (prChange.changeType !== "Deletion" && isSwaggerGeneratedByTypeSpec(prChange.filePath)) { + typeSpecLabel.shouldBePresent = true; + } + }; + }; + handlers.push({ + TypeSpecFile: typeSpecFileHandler(), + SwaggerFile: swaggerFileHandler(), + }); + await processPrChanges(ctx, handlers); + + typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processTypeSpec") +} + + +/** + * @param {string} swaggerFilePath + * @returns {boolean} + */ +function isSwaggerGeneratedByTypeSpec(swaggerFilePath) { + try { + return !!JSON.parse(readFileSync(swaggerFilePath).toString())?.info['x-typespec-generated'] + } + catch { + return false + } +} + +async function processTypeSpec( + ctx: IValidatorContext, + labelContext: LabelContext, +) { + console.log("ENTER definition processTypeSpec") + const typeSpecLabel = new Label("TypeSpec", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + typeSpecLabel.shouldBePresent = false; + const handlers: ChangeHandler[] = []; + const typeSpecFileHandler = () => { + return (prChange: PRChange) => { + // Note: this code will be executed if the PR has a diff on a TypeSpec file, + // as defined in public/swagger-validation-common/src/context.ts/defaultFilePatterns/typespec + typeSpecLabel.shouldBePresent = true; + }; + }; + const swaggerFileHandler = () => { + return (prChange: PRChange) => { + if (prChange.changeType !== "Deletion" && isSwaggerGeneratedByTypeSpec(prChange.filePath)) { + typeSpecLabel.shouldBePresent = true; + } + }; + }; + handlers.push({ + TypeSpecFile: typeSpecFileHandler(), + SwaggerFile: swaggerFileHandler(), + }); + await processPrChanges(ctx, handlers); + + typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processTypeSpec") +} + +export async function processPrChanges( + ctx: IValidatorContext, + Handlers: ChangeHandler[] +) { + console.log("ENTER definition processPrChanges") + const prChange = await getPRChanges(ctx); + prChange.forEach((prChange) => { + Handlers.forEach((handler) => { + if (prChange.fileType in handler) { + handler?.[prChange.fileType]?.(prChange); + } + }); + }); + console.log("RETURN definition processPrChanges") +} + +export async function getPRChanges(ctx: IValidatorContext): Promise { + console.log("ENTER definition getPRChanges") + const results: PRChange[] = []; + + function newChange( + fileType: FileTypes, + changeType: ChangeTypes, + filePath?: string, + additionalInfo?: any + ) { + if (filePath) { + results.push({ + filePath, + fileType, + changeType, + additionalInfo, + }); + } + } + + function newChanges( + fileType: FileTypes, + changeType: ChangeTypes, + files?: string[] + ) { + if (files) { + files.forEach((filePath) => newChange(fileType, changeType, filePath)); + } + } + + function genChanges(type: FileTypes, diffs: DiffResult) { + newChanges(type, "Addition", diffs.additions); + newChanges(type, "Deletion", diffs.deletions); + newChanges(type, "Update", diffs.changes); + } + + function genReadmeChanges(readmeDiffs?: DiffResult) { + if (readmeDiffs) { + readmeDiffs.additions?.forEach((d) => + newChange("ReadmeFile", "Addition", d.readme, d.tags) + ); + readmeDiffs.changes?.forEach((d) => + newChange("ReadmeFile", "Update", d.readme, d.tags) + ); + readmeDiffs.deletions?.forEach((d) => + newChange("ReadmeFile", "Deletion", d.readme, d.tags) + ); + } + } + + genChanges("SwaggerFile", await ctx.getSwaggerDiffs()); + genChanges("TypeSpecFile", await ctx.getTypeSpecDiffs()); + genChanges("ExampleFile", await ctx.getExampleDiffs()); + genReadmeChanges(await ctx.getReadmeDiffs()); + + console.log("RETURN definition getPRChanges") + return results; +} + +// related pending work: +// If a PR has both data-plane and resource-manager labels, it should fail with appropriate messaging #8144 +// https://github.com/Azure/azure-sdk-tools/issues/8144 +async function processPRType(context: IValidatorContext, + labelContext: LabelContext): Promise<{ resourceManagerLabelShouldBePresent: boolean }> { + console.log("ENTER definition processPRType") + const types: PRType[] = await getPRType(context); + + const resourceManagerLabelShouldBePresent = processPRTypeLabel("resource-manager", types, labelContext); + processPRTypeLabel("data-plane", types, labelContext); + + console.log("RETURN definition processPRType") + return { resourceManagerLabelShouldBePresent } +} + +export async function processPrChanges( + ctx: IValidatorContext, + Handlers: ChangeHandler[] +) { + console.log("ENTER definition processPrChanges") + const prChange = await getPRChanges(ctx); + prChange.forEach((prChange) => { + Handlers.forEach((handler) => { + if (prChange.fileType in handler) { + handler?.[prChange.fileType]?.(prChange); + } + }); + }); + console.log("RETURN definition processPrChanges") +} + +async function getPRType(context: IValidatorContext): Promise { + console.log("ENTER definition getPRType") + const prChanges: PRChange[] = await getPRChanges(context); + + logPRChanges(prChanges); + + const changedFilePaths: string[] = prChanges.map((it) => it.filePath); + + const prTypes: PRType[] = []; + if (changedFilePaths.length > 0) { + if (isDataPlanePR(changedFilePaths)) { + prTypes.push("data-plane"); + } + if (isManagementPR(changedFilePaths)) { + prTypes.push("resource-manager"); + } + } + console.log("RETURN definition getPRType") + return prTypes; + + function logPRChanges(prChanges: PRChange[]) { + console.log(`PR changes table (count: ${prChanges.length}):`); + console.log("LEGEND: changeType | fileType | filePath"); + prChanges + .map(it => `${it.changeType} | ${it.fileType} | ${it.filePath}`) + .forEach(it => console.log(it)); + console.log("END of PR changes table"); + + console.log("PR changes with additionalInfo:"); + prChanges + .filter(it => it.additionalInfo != null) + .forEach(it => console.log(it)); + console.log("END of PR changes with additionalInfo"); + } +} + +function processPRTypeLabel( + labelName: PRType, + types: PRType[], + labelContext: LabelContext +): boolean { + const label = new Label(labelName, labelContext.present); + label.shouldBePresent = types.includes(labelName); + + label.applyStateChange(labelContext.toAdd, labelContext.toRemove); + return label.shouldBePresent +} + +async function processSuppression( + context: IValidatorContext, + labelContext: LabelContext, +) { + console.log("ENTER definition processSuppression") + const suppressionReviewRequiredLabel = new Label("SuppressionReviewRequired", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + suppressionReviewRequiredLabel.shouldBePresent = false; + const handlers: ChangeHandler[] = []; + const pr = await createPullRequestProperties(context, "pr-summary"); + const createReadmeFileHandler = () => { + return (e: PRChange) => { + if ( + (e.changeType === "Addition" && getSuppressions(e.filePath).length) || + (e.changeType === "Update" && + diffSuppression(resolve(pr?.workingDir!, e.filePath), e.filePath) + .length) + ) { + suppressionReviewRequiredLabel.shouldBePresent = true; + } + }; + }; + handlers.push({ + ReadmeFile: createReadmeFileHandler(), + }); + await processPrChanges(context, handlers); + + suppressionReviewRequiredLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processSuppression") +} + +function getSuppressions(readmePath: string) { + const walkToNode = ( + walker: commonmark.NodeWalker, + cb: (node: commonmark.Node) => boolean + ): commonmark.Node | undefined => { + let event = walker.next(); + + while (event) { + const curNode = event.node; + if (cb(curNode)) { + return curNode; + } + event = walker.next(); + } + return undefined; + }; + const getAllCodeBlockNodes = (startNode: commonmark.Node) => { + const walker = startNode.walker(); + const result = []; + while (true) { + const a = walkToNode(walker, (n) => n.type === "code_block"); + if (!a) { + break; + } + result.push(a); + } + return result; + }; + let suppressionResult: any[] = []; + try { + const readme = readFileSync(readmePath).toString(); + const codeBlocks = getAllCodeBlockNodes(parseCommonmark(readme)); + for (const block of codeBlocks) { + if (block.literal) { + try { + const blockObject = yaml.safeLoad(block.literal) as any; + const directives = blockObject?.["directive"]; + if (directives && Array.isArray(directives)) { + suppressionResult = suppressionResult.concat( + directives.filter((s) => s.suppress) + ); + } + const suppressions = blockObject?.["suppressions"]; + if (suppressions && Array.isArray(suppressions)) { + suppressionResult = suppressionResult.concat(suppressions); + } + } catch (e) {} + } + } + } catch (e) {} + return suppressionResult; +} + +export function diffSuppression(readmeBefore: string, readmeAfter: string) { + const beforeSuppressions = getSuppressions(readmeBefore); + const afterSuppressions = getSuppressions(readmeAfter); + const newSuppressions = []; + for (const suppression of afterSuppressions) { + const properties = ["suppress", "from", "where", "code", "reason"]; + if ( + -1 === + beforeSuppressions.findIndex((s) => + properties.every((p) => _.isEqual(s[p], suppression[p])) + ) + ) { + newSuppressions.push(suppression); + } + } + return newSuppressions; +} + +async function processRPaaS( + context: IValidatorContext, + labelContext: LabelContext, +): Promise<{ rpaasLabelShouldBePresent: boolean }> { + console.log("ENTER definition processRPaaS") + const rpaasLabel = new Label("RPaaS", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + rpaasLabel.shouldBePresent = false; + + const handlers: ChangeHandler[] = []; + const createReadmeFileHandler = () => { + return (e: PRChange) => { + if (e.changeType !== "Deletion" && isRPSaaS(e.filePath)) { + rpaasLabel.shouldBePresent = true; + } + }; + }; + handlers.push({ + ReadmeFile: createReadmeFileHandler(), + }); + await processPrChanges(context, handlers); + + rpaasLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processRPaaS") + return { rpaasLabelShouldBePresent: rpaasLabel.shouldBePresent } +} + +function isRPSaaS(readmeFilePath: string) { + const readmeParser = new ReadmeParser(readmeFilePath); + const openapiSubtype = readmeParser.getGlobalConfigByName("openapi-subtype"); + console.log( + `readmeFilePath: ${readmeFilePath} openapi-subtype: ${openapiSubtype}` + ); + return openapiSubtype === "rpaas" || openapiSubtype === "providerHub"; +} + +async function processNewRPNamespace( + context: IValidatorContext, + labelContext: LabelContext, + resourceManagerLabelShouldBePresent: boolean +): Promise<{newRPNamespaceLabelShouldBePresent: boolean}> { + console.log("ENTER definition processNewRPNamespace") + const newRPNamespaceLabel = new Label("new-rp-namespace", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + newRPNamespaceLabel.shouldBePresent = false; + const pr: PullRequestProperties | undefined = await createPullRequestProperties( + context, + "pr-summary-new-rp-namespace" + ); + const handlers: ChangeHandler[] = []; + + let skip = false; + + const targetBranch = (context.contextConfig() as PRContext).targetBranch; + if (targetBranch !== "main" && targetBranch !== "ARMCoreRPDev") { + console.log(`Not main or ARMCoreRPDev branch, skip new RP namespace check`); + skip = true; + } + + if (!skip && !resourceManagerLabelShouldBePresent) { + console.log(`Not resource-manager, skip new RP namespace check`); + skip = true; + } + + if (!skip) { + const createSwaggerFileHandler = () => { + return (e: PRChange) => { + if (e.changeType === "Addition") { + const rpFolder = getRPFolderFromSwaggerFile(dirname(e.filePath)); + console.log(`Processing newRPNameSpace rpFolder: ${rpFolder}`); + if (rpFolder !== undefined) { + const rpFolderFullPath = resolve(pr?.workingDir!, rpFolder); + if (!existsSync(rpFolderFullPath)) { + console.log(`Adding newRPNameSpace rpFolder: ${rpFolder}`); + newRPNamespaceLabel.shouldBePresent = true; + } + } + } + }; + }; + + handlers.push({ SwaggerFile: createSwaggerFileHandler() }); + await processPrChanges(context, handlers); + } + + newRPNamespaceLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processNewRPNamespace") + return { newRPNamespaceLabelShouldBePresent: newRPNamespaceLabel.shouldBePresent } +} + +// CODESYNC: +// - see entries for related labels in https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml +// - requiredLabelsRules.ts / requiredLabelsRules +async function processNewRpNamespaceWithoutRpaasLabel( + context: IValidatorContext, + labelContext: LabelContext, + resourceManagerLabelShouldBePresent: boolean, + newRPNamespaceLabelShouldBePresent: boolean, + rpaasLabelShouldBePresent: boolean +): Promise<{ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, rpaasExceptionLabelShouldBePresent: boolean}> { + console.log("ENTER definition processNewRpNamespaceWithoutRpaasLabel") + const ciNewRPNamespaceWithoutRpaaSLabel = new Label("CI-NewRPNamespaceWithoutRPaaS", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent = false; + + const rpaasExceptionLabel = new Label("RPaaSException", labelContext.present); + + let skip = false; + + if (!resourceManagerLabelShouldBePresent) { + console.log(`Not resource-manager, skip checking for 'CI-NewRPNamespaceWithoutRPaaS'`); + skip = true; + } + + if (!skip) { + const branch = (context.contextConfig() as PRContext).targetBranch; + if (branch === "RPSaaSDev" || branch === "RPSaaSMaster") { + console.log(`PR is targeting '${branch}' branch. Skip checking for 'CI-NewRPNamespaceWithoutRPaaS'`); + skip = true; + } +} + + if ( + !skip && + newRPNamespaceLabelShouldBePresent && + !rpaasLabelShouldBePresent + ) { + console.log("Adding new RP namespace, but not RPaaS. Label 'CI-NewRPNamespaceWithoutRPaaS' should be present."); + ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent = true; + } + + rpaasExceptionLabel.shouldBePresent = rpaasExceptionLabel.present && ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent; + + ciNewRPNamespaceWithoutRpaaSLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + rpaasExceptionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processNewRpNamespaceWithoutRpaasLabel") + + return { + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: + ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent, + rpaasExceptionLabelShouldBePresent: + rpaasExceptionLabel.shouldBePresent as boolean + }; +} + +// CODESYNC: see entries for related labels in https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml +async function processRpaasRpNotInPrivateRepoLabel( + context: IValidatorContext, + labelContext: LabelContext, + resourceManagerLabelShouldBePresent: boolean, + rpaasLabelShouldBePresent: boolean +): Promise<{ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean}> { + console.log("ENTER definition processRpaasRpNotInPrivateRepoLabel") + const ciRpaasRPNotInPrivateRepoLabel = new Label("CI-RpaaSRPNotInPrivateRepo", labelContext.present); + // By default this label should not be present. We may determine later in this function that it should be present after all. + ciRpaasRPNotInPrivateRepoLabel.shouldBePresent = false; + + let skip = false; + + if (!resourceManagerLabelShouldBePresent) { + console.log(`Not resource-manager, skip RPaaSRPNotInPrivateRepo check`); + skip = true; + } + + if (!skip) { + const targetBranch = (context.contextConfig() as PRContext).targetBranch; + if (targetBranch !== "main" || !rpaasLabelShouldBePresent) { + console.log( + "Not main branch or not RPaaS PR, skip block PR when RPaaS RP not onboard" + ); + skip = true; + } + } + + if (!skip) { + + const rpaasRPFolderList = await getRPaaSFolderList(); + const rpFolderNames: string[] = rpaasRPFolderList.map((f) => f.name); + + console.log(`RPaaS RP folder list: ${rpFolderNames}`); + + const handlers: ChangeHandler[] = []; + + const processPrChange = () => { + return (e: PRChange) => { + if (e.changeType === "Addition") { + const rpFolderName = getRPRootFolderName(e.filePath); + console.log( + `Processing processRpaasRpNotInPrivateRepoLabel rpFolderName: ${rpFolderName}` + ); + + if (rpFolderName === undefined) { + console.log(`RP folder is undefined for changed file path '${e.filePath}'.`); + return; + } + + if (!rpFolderNames.includes(rpFolderName)) { + console.log( + `This RP is RPSaaS RP but could not find rpFolderName: ${rpFolderName} in RPFolderNames: ${rpFolderNames}. ` + + `Label 'CI-RpaaSRPNotInPrivateRepo' should be present.` + ); + ciRpaasRPNotInPrivateRepoLabel.shouldBePresent = true; + } + } + }; + }; + + handlers.push({ SwaggerFile: processPrChange() }); + await processPrChanges(context, handlers); + } + + ciRpaasRPNotInPrivateRepoLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + console.log("RETURN definition processRpaasRpNotInPrivateRepoLabel") + + return { ciRpaasRPNotInPrivateRepoLabelShouldBePresent: ciRpaasRPNotInPrivateRepoLabel.shouldBePresent } +} + +function warnIfLabelSetsIntersect(labelsToAdd: Set, labelsToRemove: Set) { + const intersection: string[] = _.intersection([...labelsToAdd], [...labelsToRemove]) + if (intersection.length > 0) { + console.warn("ASSERTION VIOLATION! The intersection of labelsToRemove and labelsToAdd is non-empty! " + + `labelsToAdd: [${[...labelsToAdd].join(", ")}]. ` + + `labelsToRemove: [${[...labelsToRemove].join(", ")}]. ` + + `intersection: [${intersection.join(", ")}].`) + } +} diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts new file mode 100644 index 000000000000..ca87e594a61d --- /dev/null +++ b/eng/tools/summarize-impact/src/types.ts @@ -0,0 +1,162 @@ +import { dirname, join, resolve } from "path"; + +export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; +export type ChangeTypes = "Addition" | "Deletion" | "Update"; + +export type PRChange = { + fileType: FileTypes; + changeType: ChangeTypes; + filePath: string; + additionalInfo?: any; +}; +export type ChangeHandler = { + [key in FileTypes]?: (event: PRChange) => void; +}; + +type Pattern = { + includes: string[] | string; + excludes?: string[] | string; +}; + +export type DiffResult = { + additions?: T[] + deletions?: T[] + changes?: T[] +} + +// do I need to add minimatch as a dependency? What does it do? +export const isPathMatch = ( + path: string, + pattern: string[] | string +): boolean => { + if (typeof pattern === "string") { + return minimatch(path, pattern, minimatchConfig); + } else { + return pattern.some((it) => minimatch(path, it, minimatchConfig)); + } +}; + +const isPatternMatch = (path: string, pattern: Pattern): boolean => { + if (pattern.excludes) { + if (isPathMatch(path, pattern.excludes)) { + return false; + } + } + return isPathMatch(path, pattern.includes); +}; + +export const enumerateFiles = async (path: string, pattern: Pattern) => { + let results: any[] = []; + if (await exists(path)) { + const runEnumerateFiles = ( + include: string, + excludes: string[] | string | undefined + ) => { + const absolutelyPath = join(path, include); + let ignores: string[] = []; + if (excludes) { + ignores = ignores.concat(excludes); + } + const files = glob.sync(absolutelyPath, { + ignore: ignores, + }); + return files; + }; + + if (Array.isArray(pattern.includes)) { + for (const pat of pattern.includes) { + results = results.concat(runEnumerateFiles(pat, pattern.excludes)); + } + } else { + results = runEnumerateFiles(pattern.includes, pattern.excludes); + } + } + return results; +}; + +const exists = async (path: string) => { + return existsSync(path); +}; + +const defaultFilePatterns = { + example: { + includes: "**/examples/**/*.json", + excludes: ["**/quickstart-templates/*.json", "**/schema/*.json", "**/scenarios/**/*.json","**/cadl/*.json"], + } as Pattern, + + swagger: { + includes: "**/*.json", + excludes: [ + "**/quickstart-templates/*.json", + "**/schema/*.json", + "**/scenarios/**/*.json", + "**/examples/**/*.json", + "**/package.json", + "**/package-lock.json", + "**/cadl/**/*.json" + ], + } as Pattern, + + cadl: { + includes: "**/*.cadl", + } as Pattern, + + typespec: { + includes: [ + "**/*.tsp", + // We do not include tspconfig.yml because by design it is invalid. The PR should have tspconfig.yaml instead. + // If a PR author adds tspconfig.yml the TypeSpec validation will report issue, blocking the PR from proceeding. + // Source code of this validation can be found at: + // https://github.com/Azure/azure-rest-api-specs/blob/b8c74fd80b415fa1ebb6fa787d454694c39e0fd5/eng/tools/typespec-validation/src/rules/folder-structure.ts#L27C27-L27C41 + // "**/*tspconfig.yml", + "**/*tspconfig.yaml" + ] + } as Pattern, + + readme: { includes: "**/readme.md" } as Pattern, +}; + +export type PRType = "resource-manager" | "data-plane"; + +/** + * The LabelContext is used by prSummary.ts / summary() and downstream invocations. + * + * The "present" set represents the set of labels that are currently present on the PR + * processed by given invocation of summary(). It is obtained via GitHub Octokit API at the beginning + * of summary(). + * + * The "toAdd" set is the set of labels to be added to the PR at the end of invocation of summary(). + * This is to be done by calling GitHub Octokit API to add the labels. + * + * The "toRemove" set is analogous to "toAdd" set, but instead it is the set of labels to be removed. + * + * The general pattern used in the code to populate "toAdd" or "toRemove" sets to be ready for + * Octokit invocation is as follows: + * + * - the summary() function passes the context through its invocation chain. + * - given function responsible for given label, like e.g. for label "ARMReview", + * creates a new instance of Label: const armReviewLabel = new Label("ARMReview", labelContext.present) + * - the function then processes the label to determine if armReviewLabel.shouldBePresent is to be set to true or false. + * - the function at the end of its invocation calls armReviewLabel.applyStateChanges(labelContext.toAdd, labelContext.toRemove) + * to update the sets. + * - the function may optionally return { armReviewLabel.shouldBePresent } to allow the caller to pass this value + * further to downstream business logic that depends on it. + * - at the end of invocation summary() calls Octokit passing it as input labelContext.toAdd and labelContext.toRemove. + * + * todo: this type is duplicated in JSDoc over in summarize-checks.js + */ +export type LabelContext = { + present: Set, + toAdd: Set, + toRemove: Set +} + +export type ImpactAssessment = { + prType: string[], + suppressionsChanged: boolean, + versioningReviewRequired: boolean, + breakingChangeReviewRequired: boolean, + rpaasChange: boolean, + newRP: boolean, + rpaasRPMissing: boolean +} diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts new file mode 100644 index 000000000000..9b3c40d99f71 --- /dev/null +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; + +import path from "path"; + +const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); + +// describe("invocation directory checks", () => { +// it("Should return the same path when invoked from the root of a git repo.", async () => { +// const result = await getRootFolder(REPOROOT); +// expect(result).toBe(REPOROOT); +// }); + +// it("Should return a higher path when invoked from a path deep in a git repo.", async () => { +// const result = await getRootFolder(path.join(REPOROOT, "eng", "tools", "oav-runner")); +// expect(result).toBe(REPOROOT); +// }); + +// it("Should exit with error when invoked outside of a git directory.", async () => { +// const pathOutsideRepo = path.resolve(path.join(REPOROOT, "..")); + +// const exitMock = vi +// .spyOn(process, "exit") +// .mockImplementation((code?: string | number | null | undefined) => { +// throw new Error(`Exit ${code}`); +// }); + +// await expect(getRootFolder(pathOutsideRepo)).rejects.toThrow("Exit 1"); + +// exitMock.mockRestore(); +// }); +// }); diff --git a/eng/tools/summarize-impact/tsconfig.json b/eng/tools/summarize-impact/tsconfig.json new file mode 100644 index 000000000000..5f48d4c6a5b5 --- /dev/null +++ b/eng/tools/summarize-impact/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "allowJs": true, + }, + "include": ["*.ts", "src/**/*.ts", "test/**/*.ts"], +} diff --git a/package-lock.json b/package-lock.json index d88288dda972..7f2617077d93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,6 +109,7 @@ "@azure-tools/openapi-diff-runner": "file:openapi-diff-runner", "@azure-tools/sdk-suppressions": "file:sdk-suppressions", "@azure-tools/spec-gen-sdk-runner": "file:spec-gen-sdk-runner", + "@azure-tools/summarize-impact": "file:summarize-impact", "@azure-tools/suppressions": "file:suppressions", "@azure-tools/tsp-client-tests": "file:tsp-client-tests", "@azure-tools/typespec-migration-validation": "file:typespec-migration-validation", @@ -454,6 +455,27 @@ "node": ">=20.0.0" } }, + "eng/tools/summarize-impact": { + "name": "@azure-tools/oav-runner", + "dev": true, + "dependencies": { + "@azure-tools/specs-shared": "file:../../../.github/shared", + "js-yaml": "^4.1.0", + "simple-git": "^3.27.0" + }, + "bin": { + "oav-runner": "cmd/oav-runner.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "prettier": "~3.5.3", + "typescript": "~5.8.2", + "vitest": "^3.0.7" + }, + "engines": { + "node": ">=20.0.0" + } + }, "eng/tools/suppressions": { "name": "@azure-tools/suppressions", "dev": true, @@ -933,6 +955,10 @@ "resolved": ".github/shared", "link": true }, + "node_modules/@azure-tools/summarize-impact": { + "resolved": "eng/tools/summarize-impact", + "link": true + }, "node_modules/@azure-tools/suppressions": { "resolved": "eng/tools/suppressions", "link": true From cd971b338612a7f789889d1f40b9982e2c9ae679 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 15 Jul 2025 23:07:49 +0000 Subject: [PATCH 12/67] making a ton of progress. almnomost got it! --- .github/workflows/summarize-impact.yaml | 16 +- eng/tools/summarize-impact/package.json | 7 +- eng/tools/summarize-impact/src/cli.ts | 81 +++++----- eng/tools/summarize-impact/src/runner.ts | 113 ++++---------- eng/tools/summarize-impact/src/types.ts | 191 +++++++++++++++++++++++ 5 files changed, 276 insertions(+), 132 deletions(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 307f70b98224..3a622e2881af 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -4,9 +4,10 @@ on: pull_request permissions: contents: read + pull-requests: read jobs: - lintdiff: + impact: name: "Summarize PR Impact" runs-on: ubuntu-24.04 @@ -33,15 +34,24 @@ jobs: - name: Setup Node and install deps uses: ./.github/actions/setup-node-install-deps + - name: Get PR labels + id: get-labels + run: | + labels=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '[.labels[].name] | join(",")') + echo "labels=$labels" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ github.token }} + - name: Run Summarize Impact id: summarize-impact run: | - npm exec --no -- summarize-impact -d after \ + npm exec --no -- summarize-impact --sourceDirectory after --targetDirectory before \ --number "${{ github.event.pull_request.number }}" \ --sb "${{ github.event.pull_request.head.ref }}" \ --tb "${{ github.event.pull_request.base.ref }}" \ --sha "${{ github.event.pull_request.head.sha }}" \ - --repo "${{ github.repository }}" + --repo "${{ github.repository }}" \ + --labels "${{ steps.get-labels.outputs.labels }}" env: # We absolutely need to avoid OOM errors due to certain inherited types from openapi-alps NODE_OPTIONS: "--max-old-space-size=8192" diff --git a/eng/tools/summarize-impact/package.json b/eng/tools/summarize-impact/package.json index fd5fe69aa39f..6813ce3353ed 100644 --- a/eng/tools/summarize-impact/package.json +++ b/eng/tools/summarize-impact/package.json @@ -17,14 +17,19 @@ "dependencies": { "@azure-tools/specs-shared": "file:../../../.github/shared", "glob": "latest", + "@ts-common/commonmark-to-markdown": "^2.0.2", + "commonmark": "^0.29.0", "js-yaml": "^4.1.0", + "lodash": "^4.17.20", "simple-git": "^3.27.0" }, "devDependencies": { "@types/node": "^20.0.0", "prettier": "~3.5.3", + "@types/lodash": "^4.14.161", "typescript": "~5.8.2", - "vitest": "^3.0.7" + "vitest": "^3.0.7", + "@types/commonmark": "^0.27.4" }, "engines": { "node": ">=20.0.0" diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 278206bf41f6..d54a540499cb 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -1,13 +1,13 @@ #!/usr/bin/env node import { evaluateImpact } from "./runner.js"; -import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; +import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files"; import { resolve } from "path"; import { parseArgs, ParseArgsConfig } from "node:util"; import fs from "node:fs/promises"; import { simpleGit } from "simple-git"; -import { extractInputs } from "@azure-tools/specs-shared/context"; +import { LabelContext, PRContext } from "./types.js"; export async function getRootFolder(inputPath: string): Promise { try { @@ -25,9 +25,14 @@ export async function getRootFolder(inputPath: string): Promise { export async function main() { const config: ParseArgsConfig = { options: { + // the target branch checked out targetDirectory: { type: "string", - short: "d", + multiple: false, + }, + // the pr + sourceDirectory: { + type: "string", multiple: false, default: process.cwd(), }, @@ -61,59 +66,49 @@ export async function main() { type: "string", short: "r", multiple: false, + }, + labels: { + type: "string", + short: "l", + multiple: false } }, allowPositionals: true, }; const { values: opts, positionals } = parseArgs(config); - console.log(positionals); - // this option has a default value of process.cwd(), so we can assume it is always defined - // just need to resolve that here to make ts aware of it - const targetDirectory = opts.targetDirectory as string; - - const resolvedGitRoot = await getRootFolder(targetDirectory); - - let fileList: string[] | undefined = undefined; - if (opts.fileList !== undefined) { - const fileListPath = resolve(opts.fileList as string); - try { - const fileContent = await fs.readFile(fileListPath, { encoding: "utf-8" }); - fileList = fileContent - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - console.log(`Loaded ${fileList.length} files from ${opts.fileList}`); - } catch (error) { - console.error( - `Error reading file list from ${opts.fileList}: ${error instanceof Error ? error.message : String(error)}`, - ); - console.error("User provided file list that is not found."); - console.error( - "Please ensure the file exists and is readable, or do not provide the option 'fileList'", - ); - process.exit(1); - } - } - else { - if (!fileList){ - await getChangedFiles({ cwd: resolvedGitRoot }); - } - } + // todo: refactor these opts? They're not really options. I just don't want a bunch of positional args for clarity's sake + const sourceDirectory = opts.sourceDirectory as string; + const targetDirectory = opts.targetDirectory as string; + const sourceGitRoot = await getRootFolder(sourceDirectory); + const targetGitRoot = await getRootFolder(targetDirectory) + const fileList = await getChangedFilesStatuses({ cwd: sourceGitRoot }); const sha = opts.sha as string; const sourceBranch = opts.sourceBranch as string; const targetBranch = opts.targetBranch as string; const repo = opts.repo as string; const prNumber = opts.number as string; + const existingLabels = (opts.labels as string).split(",").map((l) => l.trim()); - const context: any = { - sha, - sourceBranch, - targetBranch, - repo, - prNumber + const labelContext: LabelContext = { + present: new Set(existingLabels), + toAdd: new Set(), + toRemove: new Set() }; - evaluateImpact(context, resolvedGitRoot, fileList,); + const prContext = new PRContext( + sourceGitRoot, + targetGitRoot, + labelContext, + { + sha, + sourceBranch, + targetBranch, + repo, + prNumber, + fileList + }); + + evaluateImpact(prContext, labelContext); } diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index 3523d0d36dd1..03e44c793219 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -1,23 +1,19 @@ #!/usr/bin/env node -// import * as path from "path"; import * as fs from "fs"; -// import { glob } from "glob"; - +import * as commonmark from "commonmark"; +import yaml from "js-yaml"; +import { glob } from "glob"; +import * as _ from "lodash"; +import * as path from "path"; // import { Swagger } from "@azure-tools/specs-shared/swagger"; // import { includesFolder } from "@azure-tools/specs-shared/path"; // import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; //getChangedFiles, -import { FileTypes, ChangeTypes, PRChange, ChangeHandler, PRType, ImpactAssessment, LabelContext } from "./types.js" - -export async function evaluateImpact(context: PRContext, targetDirectory: string, existingLabels: string[], fileList?: string[]): Promise { - const labelContext: LabelContext = { - present: new Set(existingLabels), - toAdd: new Set(), - toRemove: new Set() - }; +import { FileTypes, ChangeTypes, PRChange, ChangeHandler, PRType, ImpactAssessment, LabelContext, PRContext, Label, DiffResult, ReadmeTag } from "./types.js" +export async function evaluateImpact(context: PRContext, labelContext: LabelContext): Promise { // examine changed files. if changedpaths includes data-plane, add "data-plane" // same for "resource-manager". We care about whether resourcemanager will be present for a later check const { resourceManagerLabelShouldBePresent } = await processPRType( @@ -91,7 +87,6 @@ export function isDataPlanePR(filePaths: string[]): boolean { return filePaths.some((it) => it.includes("data-plane")); } - export function getAllApiVersionFromRPFolder(rpFolder: string): string[] { const allSwaggerFilesFromRPFolder = glob.sync(`${rpFolder}/**/*.json`); console.log(`allSwaggerFilesFromRPFolder: ${allSwaggerFilesFromRPFolder}`); @@ -173,7 +168,7 @@ export const getResourceProviderFromFilePath = ( return undefined; }; -async function isNewApiVersion(context: IValidatorContext): Promise { +async function isNewApiVersion(context: PRContext): Promise { const pr = await createPullRequestProperties( context, "pr-summary-new-api-version" @@ -219,7 +214,7 @@ async function isNewApiVersion(context: IValidatorContext): Promise { return false; } - const targetBranchRPFolder = resolve(pr?.workingDir!, firstRPFolder); + const targetBranchRPFolder = path.resolve(pr?.workingDir!, firstRPFolder); console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); @@ -369,19 +364,20 @@ async function processBreakingChangeLabels( */ function checkPrTargetsProductionBranch(prContext: PRContext): boolean { + const targetsPublicProductionBranch = - prContext.sourceRepoUri.includes("azure-rest-api-specs") - && !prContext.sourceRepoUri.includes("azure-rest-api-specs-pr") - && prContext.targetBranch == "main" + prContext.repo.includes("azure-rest-api-specs") + && prContext.repo !== "azure-rest-api-specs-pr" + && prContext.targetBranch === "main"; const targetsPrivateProductionBranch = - prContext.sourceRepoUri.includes("azure-rest-api-specs-pr") - && prContext.targetBranch == "RPSaaSMaster" + prContext.repo === "azure-rest-api-specs-pr" + && prContext.targetBranch === "RPSaaSMaster" return targetsPublicProductionBranch || targetsPrivateProductionBranch } -\async function processTypeSpec( +async function processTypeSpec( ctx: IValidatorContext, labelContext: LabelContext, ) { @@ -414,60 +410,22 @@ function checkPrTargetsProductionBranch(prContext: PRContext): boolean { console.log("RETURN definition processTypeSpec") } - -/** - * @param {string} swaggerFilePath - * @returns {boolean} - */ -function isSwaggerGeneratedByTypeSpec(swaggerFilePath) { +function isSwaggerGeneratedByTypeSpec(swaggerFilePath: string): boolean { try { - return !!JSON.parse(readFileSync(swaggerFilePath).toString())?.info['x-typespec-generated'] + return !!JSON.parse(fs.readFileSync(swaggerFilePath).toString())?.info['x-typespec-generated'] } catch { return false } } -async function processTypeSpec( - ctx: IValidatorContext, - labelContext: LabelContext, -) { - console.log("ENTER definition processTypeSpec") - const typeSpecLabel = new Label("TypeSpec", labelContext.present); - // By default this label should not be present. We may determine later in this function that it should be present after all. - typeSpecLabel.shouldBePresent = false; - const handlers: ChangeHandler[] = []; - const typeSpecFileHandler = () => { - return (prChange: PRChange) => { - // Note: this code will be executed if the PR has a diff on a TypeSpec file, - // as defined in public/swagger-validation-common/src/context.ts/defaultFilePatterns/typespec - typeSpecLabel.shouldBePresent = true; - }; - }; - const swaggerFileHandler = () => { - return (prChange: PRChange) => { - if (prChange.changeType !== "Deletion" && isSwaggerGeneratedByTypeSpec(prChange.filePath)) { - typeSpecLabel.shouldBePresent = true; - } - }; - }; - handlers.push({ - TypeSpecFile: typeSpecFileHandler(), - SwaggerFile: swaggerFileHandler(), - }); - await processPrChanges(ctx, handlers); - - typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processTypeSpec") -} - export async function processPrChanges( - ctx: IValidatorContext, + ctx: PRContext, Handlers: ChangeHandler[] ) { console.log("ENTER definition processPrChanges") - const prChange = await getPRChanges(ctx); - prChange.forEach((prChange) => { + const prChanges = await getPRChanges(ctx); + prChanges.forEach((prChange) => { Handlers.forEach((handler) => { if (prChange.fileType in handler) { handler?.[prChange.fileType]?.(prChange); @@ -477,7 +435,7 @@ export async function processPrChanges( console.log("RETURN definition processPrChanges") } -export async function getPRChanges(ctx: IValidatorContext): Promise { +export async function getPRChanges(ctx: PRContext): Promise { console.log("ENTER definition getPRChanges") const results: PRChange[] = []; @@ -539,7 +497,7 @@ export async function getPRChanges(ctx: IValidatorContext): Promise // related pending work: // If a PR has both data-plane and resource-manager labels, it should fail with appropriate messaging #8144 // https://github.com/Azure/azure-sdk-tools/issues/8144 -async function processPRType(context: IValidatorContext, +async function processPRType(context: PRContext, labelContext: LabelContext): Promise<{ resourceManagerLabelShouldBePresent: boolean }> { console.log("ENTER definition processPRType") const types: PRType[] = await getPRType(context); @@ -551,22 +509,6 @@ async function processPRType(context: IValidatorContext, return { resourceManagerLabelShouldBePresent } } -export async function processPrChanges( - ctx: IValidatorContext, - Handlers: ChangeHandler[] -) { - console.log("ENTER definition processPrChanges") - const prChange = await getPRChanges(ctx); - prChange.forEach((prChange) => { - Handlers.forEach((handler) => { - if (prChange.fileType in handler) { - handler?.[prChange.fileType]?.(prChange); - } - }); - }); - console.log("RETURN definition processPrChanges") -} - async function getPRType(context: IValidatorContext): Promise { console.log("ENTER definition getPRType") const prChanges: PRChange[] = await getPRChanges(context); @@ -616,21 +558,22 @@ function processPRTypeLabel( } async function processSuppression( - context: IValidatorContext, + context: PRContext, labelContext: LabelContext, ) { console.log("ENTER definition processSuppression") + const suppressionReviewRequiredLabel = new Label("SuppressionReviewRequired", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. suppressionReviewRequiredLabel.shouldBePresent = false; const handlers: ChangeHandler[] = []; - const pr = await createPullRequestProperties(context, "pr-summary"); + const createReadmeFileHandler = () => { return (e: PRChange) => { if ( (e.changeType === "Addition" && getSuppressions(e.filePath).length) || (e.changeType === "Update" && - diffSuppression(resolve(pr?.workingDir!, e.filePath), e.filePath) + diffSuppression(path.resolve(context.targetDirectory, e.filePath), path.resolve(context.sourceDirectory,e.filePath)) .length) ) { suppressionReviewRequiredLabel.shouldBePresent = true; @@ -676,7 +619,7 @@ function getSuppressions(readmePath: string) { }; let suppressionResult: any[] = []; try { - const readme = readFileSync(readmePath).toString(); + const readme = fs.readFileSync(readmePath).toString(); const codeBlocks = getAllCodeBlockNodes(parseCommonmark(readme)); for (const block of codeBlocks) { if (block.literal) { diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index ca87e594a61d..3e93e5e85c79 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -9,6 +9,12 @@ export type PRChange = { filePath: string; additionalInfo?: any; }; + +export type ReadmeTag = { + readme: string; + tags: DiffResult; +}; + export type ChangeHandler = { [key in FileTypes]?: (event: PRChange) => void; }; @@ -160,3 +166,188 @@ export type ImpactAssessment = { newRP: boolean, rpaasRPMissing: boolean } + +export class Label { + name: string; + + /** Is the label currently present on the pull request? + * + * This is determined at the time of construction of this object. + */ + present?: boolean; + + /** Should this label be present on the pull request? + * + * Must be defined before applyStateChange is called. + * + * Not set at the construction time to facilitate determining desired presence + * of multiple labels in single code block, without intermixing it with + * label construction logic. + */ + shouldBePresent: boolean | undefined = undefined; + + constructor(name: string, presentLabels?: Set) { + this.name = name; + this.present = presentLabels?.has(this.name) ?? undefined; + } + + /** + * If the label should be added, add its name to labelsToAdd. + * If the label should be removed, add its name to labelsToRemove. + * Otherwise, do nothing. + * + * Precondition: this.shouldBePresent has been defined. + */ + applyStateChange( + labelsToAdd: Set, + labelsToRemove: Set + ): void { + if (this.shouldBePresent === undefined) { + console.warn( + "ASSERTION VIOLATION! " + + `Cannot applyStateChange for label '${this.name}' ` + + "as its desired presence hasn't been defined. Returning early." + ); + return; + } + + if (!this.present && this.shouldBePresent) { + if (!labelsToAdd.has(this.name)) { + console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`); + labelsToAdd.add(this.name); + } else { + console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`); + } + } else if (this.present && !this.shouldBePresent) { + if (!labelsToRemove.has(this.name)) { + console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`); + labelsToRemove.add(this.name); + } else { + console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`); + } + } else if (this.present === this.shouldBePresent) { + console.log(`Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`); + } else { + console.warn( + "ASSERTION VIOLATION! " + + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` + + `At this point of execution this should not happen.` + ); + } + } + + isEqualToOrPrefixOf(label: string): boolean { + return this.name.endsWith("*") + ? label.startsWith(this.name.slice(0, -1)) + : this.name === label; + } + + logString(): string { + return ( + `Label: name: ${this.name}, ` + + `present: ${this.present}, ` + + `shouldBePresent: ${this.shouldBePresent}. ` + ); + } +} + +export type FileListInfo = { + additions: string[]; + modifications: string[]; + deletions: string[]; + renames: { from: string; to: string }[]; + total: number; +}; + +export type PRContextOptions = { + fileList?: FileListInfo; + sourceBranch: string; + targetBranch: string; + sha: string; + repo: string; + prNumber: string; +}; + +export class PRContext { + // we are starting with checking out before and after to different directories + // sourceDirectory corresponds to "after". EG the PR context is the "after" state of the PR. + // The "before" state is the git root directory without the changes aka targetDirectory + sourceDirectory: string; + targetDirectory: string; + fileList?: FileListInfo; + sourceBranch: string; + targetBranch: string; + sha: string; + repo: string; + prNumber: string; + labelContext: LabelContext; + + constructor( + sourceDirectory: string, + targetDirectory: string, + labelContext: LabelContext, + options: PRContextOptions + ) { + this.sourceDirectory = sourceDirectory; + this.targetDirectory = targetDirectory; + this.labelContext = labelContext; + this.sourceBranch = options.sourceBranch; + this.targetBranch = options.targetBranch; + this.sha = options.sha; + this.repo = options.repo; + this.prNumber = options.prNumber; + this.fileList = options.fileList; + } + + // this is the core of the logic. + + async getTypeSpecDiffs(): Promise> { + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + + async getSwaggerDiffs(): Promise> { + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + async getExampleDiffs(): Promise> { + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + + async getReadmeDiffs(): Promise> { + // console.log("ENTER definition LocalDirContext.getReadmeDiffs") + // if (this.readmeDiffs) { + // console.log("RETURN definition LocalDirContext.getReadmeDiffs - early return") + // return this.readmeDiffs; + // } + // const readmeDiffs = await enumerateFiles( + // this.context.swaggerDirs[0], + // this.options?.pattern?.readme || defaultFilePatterns.readme + // ); + // const readmeTags = readmeDiffs.map((readmeDiff) => { + // return { + // readme: readmeDiff as string, + // tags: toDiffResult(getAllTags(readFileSync(readmeDiff as string).toString())), + // } as ReadmeTag; + // }); + // this.readmeDiffs = { additions: readmeTags }; + // console.log("RETURN definition LocalDirContext.getReadmeDiffs") + // return this.readmeDiffs; + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + +} From 07799391fa08dcfb0cf9563f5bd9b26cafcd6627 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Wed, 16 Jul 2025 01:39:42 +0000 Subject: [PATCH 13/67] lotta changes for stability. we are actually getting pretty damn close to making this work --- .github/shared/src/changed-files.js | 12 ++++ .github/shared/test/changed-files.test.js | 7 +++ eng/tools/summarize-impact/src/runner.ts | 33 +++++----- eng/tools/summarize-impact/src/types.ts | 76 +++++++++++++++++++---- 4 files changed, 98 insertions(+), 30 deletions(-) diff --git a/.github/shared/src/changed-files.js b/.github/shared/src/changed-files.js index 66ac8512e491..0e11d4e58ef3 100644 --- a/.github/shared/src/changed-files.js +++ b/.github/shared/src/changed-files.js @@ -201,6 +201,18 @@ export function example(file) { ); } +/** + * @param {string} file + * @returns {boolean} + */ +export function typespec(file) { + return ( + typeof file === "string" && + (file.toLowerCase().endsWith(".tsp") || file.toLowerCase().endsWith("tspconfig.yaml")) && + specification(file) + ); +} + /** * @param {string} [file] * @returns {boolean} diff --git a/.github/shared/test/changed-files.test.js b/.github/shared/test/changed-files.test.js index ae6feda82191..e38840beb3e1 100644 --- a/.github/shared/test/changed-files.test.js +++ b/.github/shared/test/changed-files.test.js @@ -20,6 +20,7 @@ import { specification, swagger, scenario, + typespec, } from "../src/changed-files.js"; import { debugLogger } from "../src/logger.js"; @@ -48,6 +49,7 @@ describe("changedFiles", () => { "README.MD", "specification/contosowidgetmanager/data-plane/readme.md", "specification/contosowidgetmanager/Contoso.Management/main.tsp", + "specification/contosowidgetmanager/Contoso.Management/tspconfig.yaml", "specification/contosowidgetmanager/Contoso.Management/examples/2021-11-01/Employees_Get.json", "specification/contosowidgetmanager/resource-manager/readme.md", "specification/contosowidgetmanager/resource-manager/Microsoft.Contoso/stable/2021-11-01/contoso.json", @@ -92,6 +94,11 @@ describe("changedFiles", () => { expect(files.filter(specification)).toEqual(expected); }); + it("filter:typespec", () => { + const expected = ["specification/contosowidgetmanager/Contoso.Management/main.tsp"]; + expect(files.filter(typespec)).toEqual(expected); + }); + it("filter:data-plane", () => { const expected = ["specification/contosowidgetmanager/data-plane/readme.md"]; diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index 03e44c793219..ad7437f6add3 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -378,7 +378,7 @@ function checkPrTargetsProductionBranch(prContext: PRContext): boolean { } async function processTypeSpec( - ctx: IValidatorContext, + ctx: PRContext, labelContext: LabelContext, ) { console.log("ENTER definition processTypeSpec") @@ -509,7 +509,7 @@ async function processPRType(context: PRContext, return { resourceManagerLabelShouldBePresent } } -async function getPRType(context: IValidatorContext): Promise { +async function getPRType(context: PRContext): Promise { console.log("ENTER definition getPRType") const prChanges: PRChange[] = await getPRChanges(context); @@ -620,11 +620,11 @@ function getSuppressions(readmePath: string) { let suppressionResult: any[] = []; try { const readme = fs.readFileSync(readmePath).toString(); - const codeBlocks = getAllCodeBlockNodes(parseCommonmark(readme)); + const codeBlocks = getAllCodeBlockNodes(new commonmark.Parser().parse(readme)); for (const block of codeBlocks) { if (block.literal) { try { - const blockObject = yaml.safeLoad(block.literal) as any; + const blockObject = yaml.load(block.literal) as any; const directives = blockObject?.["directive"]; if (directives && Array.isArray(directives)) { suppressionResult = suppressionResult.concat( @@ -661,7 +661,7 @@ export function diffSuppression(readmeBefore: string, readmeAfter: string) { } async function processRPaaS( - context: IValidatorContext, + context: PRContext, labelContext: LabelContext, ): Promise<{ rpaasLabelShouldBePresent: boolean }> { console.log("ENTER definition processRPaaS") @@ -697,7 +697,7 @@ function isRPSaaS(readmeFilePath: string) { } async function processNewRPNamespace( - context: IValidatorContext, + context: PRContext, labelContext: LabelContext, resourceManagerLabelShouldBePresent: boolean ): Promise<{newRPNamespaceLabelShouldBePresent: boolean}> { @@ -705,15 +705,11 @@ async function processNewRPNamespace( const newRPNamespaceLabel = new Label("new-rp-namespace", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. newRPNamespaceLabel.shouldBePresent = false; - const pr: PullRequestProperties | undefined = await createPullRequestProperties( - context, - "pr-summary-new-rp-namespace" - ); const handlers: ChangeHandler[] = []; let skip = false; - const targetBranch = (context.contextConfig() as PRContext).targetBranch; + const targetBranch = context.targetBranch; if (targetBranch !== "main" && targetBranch !== "ARMCoreRPDev") { console.log(`Not main or ARMCoreRPDev branch, skip new RP namespace check`); skip = true; @@ -728,11 +724,11 @@ async function processNewRPNamespace( const createSwaggerFileHandler = () => { return (e: PRChange) => { if (e.changeType === "Addition") { - const rpFolder = getRPFolderFromSwaggerFile(dirname(e.filePath)); + const rpFolder = getRPFolderFromSwaggerFile(path.dirname(e.filePath)); console.log(`Processing newRPNameSpace rpFolder: ${rpFolder}`); if (rpFolder !== undefined) { - const rpFolderFullPath = resolve(pr?.workingDir!, rpFolder); - if (!existsSync(rpFolderFullPath)) { + const rpFolderFullPath = path.resolve(context.targetDirectory, rpFolder); + if (!fs.existsSync(rpFolderFullPath)) { console.log(`Adding newRPNameSpace rpFolder: ${rpFolder}`); newRPNamespaceLabel.shouldBePresent = true; } @@ -754,7 +750,7 @@ async function processNewRPNamespace( // - see entries for related labels in https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml // - requiredLabelsRules.ts / requiredLabelsRules async function processNewRpNamespaceWithoutRpaasLabel( - context: IValidatorContext, + context: PRContext, labelContext: LabelContext, resourceManagerLabelShouldBePresent: boolean, newRPNamespaceLabelShouldBePresent: boolean, @@ -775,7 +771,7 @@ async function processNewRpNamespaceWithoutRpaasLabel( } if (!skip) { - const branch = (context.contextConfig() as PRContext).targetBranch; + const branch = context.targetBranch; if (branch === "RPSaaSDev" || branch === "RPSaaSMaster") { console.log(`PR is targeting '${branch}' branch. Skip checking for 'CI-NewRPNamespaceWithoutRPaaS'`); skip = true; @@ -807,7 +803,7 @@ async function processNewRpNamespaceWithoutRpaasLabel( // CODESYNC: see entries for related labels in https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml async function processRpaasRpNotInPrivateRepoLabel( - context: IValidatorContext, + context: PRContext, labelContext: LabelContext, resourceManagerLabelShouldBePresent: boolean, rpaasLabelShouldBePresent: boolean @@ -825,7 +821,7 @@ async function processRpaasRpNotInPrivateRepoLabel( } if (!skip) { - const targetBranch = (context.contextConfig() as PRContext).targetBranch; + const targetBranch = context.targetBranch; if (targetBranch !== "main" || !rpaasLabelShouldBePresent) { console.log( "Not main branch or not RPaaS PR, skip block PR when RPaaS RP not onboard" @@ -835,7 +831,6 @@ async function processRpaasRpNotInPrivateRepoLabel( } if (!skip) { - const rpaasRPFolderList = await getRPaaSFolderList(); const rpFolderNames: string[] = rpaasRPFolderList.map((f) => f.name); diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index 3e93e5e85c79..be7899da600c 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -1,4 +1,9 @@ -import { dirname, join, resolve } from "path"; +import { join } from "path"; + + +import { Readme } from "@azure-tools/specs-shared/readme"; +import { SpecModel } from "@azure-tools/specs-shared/spec-model"; +import { swagger, typespec, readme, example } from "@azure-tools/specs-shared/changed-files"; export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; export type ChangeTypes = "Addition" | "Deletion" | "Update"; @@ -274,6 +279,8 @@ export class PRContext { // The "before" state is the git root directory without the changes aka targetDirectory sourceDirectory: string; targetDirectory: string; + sourceSpecModel?: SpecModel; + targetSpecModel?: SpecModel; fileList?: FileListInfo; sourceBranch: string; targetBranch: string; @@ -290,6 +297,8 @@ export class PRContext { ) { this.sourceDirectory = sourceDirectory; this.targetDirectory = targetDirectory; + this.sourceSpecModel = new SpecModel(sourceDirectory); + this.targetSpecModel = new SpecModel(targetDirectory); this.labelContext = labelContext; this.sourceBranch = options.sourceBranch; this.targetBranch = options.targetBranch; @@ -299,32 +308,77 @@ export class PRContext { this.fileList = options.fileList; } - // this is the core of the logic. + // todo get rid of async here not necessary + // todo store the results async getTypeSpecDiffs(): Promise> { + if (!this.fileList) { + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + + const additions = this.fileList.additions.filter(file => typespec(file)); + const deletions = this.fileList.deletions.filter(file => typespec(file)); + const changes = this.fileList.modifications.filter(file => typespec(file)); + return Promise.resolve({ - additions: [], - deletions: [], - changes: [] + additions, + deletions, + changes }); } async getSwaggerDiffs(): Promise> { + if (!this.fileList) { + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + + const additions = this.fileList.additions.filter(file => swagger(file)); + const deletions = this.fileList.deletions.filter(file => swagger(file)); + const changes = this.fileList.modifications.filter(file => swagger(file)); + return Promise.resolve({ - additions: [], - deletions: [], - changes: [] + additions, + deletions, + changes }); } + async getExampleDiffs(): Promise> { + if (!this.fileList) { + return Promise.resolve({ + additions: [], + deletions: [], + changes: [] + }); + } + + const additions = this.fileList.additions.filter(file => example(file)); + const deletions = this.fileList.deletions.filter(file => example(file)); + const changes = this.fileList.modifications.filter(file => example(file)); + return Promise.resolve({ - additions: [], - deletions: [], - changes: [] + additions, + deletions, + changes }); } async getReadmeDiffs(): Promise> { + // get all the readme files + // generate the diffresult from it. + // Readme + + // console.log(`ahahahah readme tags are ${rmtags}`) + + // console.log("ENTER definition LocalDirContext.getReadmeDiffs") // if (this.readmeDiffs) { // console.log("RETURN definition LocalDirContext.getReadmeDiffs - early return") From 7b702b7f5457986737d7e114eb86e9f6fcd9a69e Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Wed, 16 Jul 2025 21:04:22 +0000 Subject: [PATCH 14/67] clean build. time to start integration testing while eliminating the last bit of problematic code --- .github/workflows/summarize-impact.yaml | 1 + eng/tools/summarize-impact/src/cli.ts | 18 +- eng/tools/summarize-impact/src/runner.ts | 263 ++++++++++---------- eng/tools/summarize-impact/src/types.ts | 205 +++++++-------- eng/tools/summarize-impact/test/cli.test.ts | 18 +- 5 files changed, 276 insertions(+), 229 deletions(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 3a622e2881af..0a0d47f56b13 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -51,6 +51,7 @@ jobs: --tb "${{ github.event.pull_request.base.ref }}" \ --sha "${{ github.event.pull_request.head.sha }}" \ --repo "${{ github.repository }}" \ + --owner "${{ github.repository_owner }}" \ --labels "${{ steps.get-labels.outputs.labels }}" env: # We absolutely need to avoid OOM errors due to certain inherited types from openapi-alps diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index d54a540499cb..853abda89852 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -5,7 +5,6 @@ import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files import { resolve } from "path"; import { parseArgs, ParseArgsConfig } from "node:util"; -import fs from "node:fs/promises"; import { simpleGit } from "simple-git"; import { LabelContext, PRContext } from "./types.js"; @@ -67,16 +66,25 @@ export async function main() { short: "r", multiple: false, }, + owner: { + type: "string", + short: "o", + multiple: false, + }, labels: { type: "string", short: "l", multiple: false + }, + prStatus: { + type: "string", + multiple: false } }, allowPositionals: true, }; - const { values: opts, positionals } = parseArgs(config); + const { values: opts } = parseArgs(config); // todo: refactor these opts? They're not really options. I just don't want a bunch of positional args for clarity's sake const sourceDirectory = opts.sourceDirectory as string; @@ -88,8 +96,10 @@ export async function main() { const sourceBranch = opts.sourceBranch as string; const targetBranch = opts.targetBranch as string; const repo = opts.repo as string; + const owner = opts.owner as string; const prNumber = opts.number as string; const existingLabels = (opts.labels as string).split(",").map((l) => l.trim()); + const prStatus = opts.prStatus as string; const labelContext: LabelContext = { present: new Set(existingLabels), @@ -107,7 +117,9 @@ export async function main() { targetBranch, repo, prNumber, - fileList + owner, + fileList, + prStatus }); evaluateImpact(prContext, labelContext); diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index ad7437f6add3..47e1a0a0f72b 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -7,13 +7,22 @@ import { glob } from "glob"; import * as _ from "lodash"; import * as path from "path"; + +import { breakingChangesCheckType } from "@azure-tools/specs-shared/breaking-change"; + // import { Swagger } from "@azure-tools/specs-shared/swagger"; // import { includesFolder } from "@azure-tools/specs-shared/path"; // import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; //getChangedFiles, import { FileTypes, ChangeTypes, PRChange, ChangeHandler, PRType, ImpactAssessment, LabelContext, PRContext, Label, DiffResult, ReadmeTag } from "./types.js" +export declare const breakingChangeLabelVarName = "breakingChangeVar"; +export declare const crossVersionBreakingChangeLabelVarName = "crossVersionBreakingChangeVar"; + export async function evaluateImpact(context: PRContext, labelContext: LabelContext): Promise { + + const typeSpecLabelShouldBePresent = await processTypeSpec(context, labelContext); + // examine changed files. if changedpaths includes data-plane, add "data-plane" // same for "resource-manager". We care about whether resourcemanager will be present for a later check const { resourceManagerLabelShouldBePresent } = await processPRType( @@ -31,7 +40,7 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont const { versioningReviewRequiredLabelShouldBePresent, breakingChangeReviewRequiredLabelShouldBePresent, - } = await processBreakingChangeLabels(prContext, labelContext); + } = await processBreakingChangeLabels(context, labelContext); // needs to examine "after" context to understand if a readme that was changed is RPaaS or not const { rpaasLabelShouldBePresent } = await processRPaaS( @@ -68,14 +77,20 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont rpaasLabelShouldBePresent ); + + // todo: + console.log(ciRpaasRPNotInPrivateRepoLabelShouldBePresent) return { prType: [], - suppressionsChanged: labelContext.toAdd.has("suppressionsReviewRequired"), + suppressionReviewRequired: labelContext.toAdd.has("suppressionsReviewRequired"), versioningReviewRequired: versioningReviewRequiredLabelShouldBePresent, breakingChangeReviewRequired: breakingChangeReviewRequiredLabelShouldBePresent, rpaasChange: rpaasLabelShouldBePresent, newRP: newRPNamespaceLabelShouldBePresent, - rpaasRPMissing: ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent || rpaasExceptionLabelShouldBePresent + rpaasRPMissing: ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionRequired: rpaasExceptionLabelShouldBePresent, + typeSpecChanged: typeSpecLabelShouldBePresent, + isDraft: context.prStatus === "draft", }; } @@ -168,71 +183,72 @@ export const getResourceProviderFromFilePath = ( return undefined; }; -async function isNewApiVersion(context: PRContext): Promise { - const pr = await createPullRequestProperties( - context, - "pr-summary-new-api-version" - ); - const handlers: ChangeHandler[] = []; - let isAddingNewApiVersion = false; - const apiVersionSet = new Set(); - - const rpFolders = new Set(); - - const createSwaggerFileHandler = () => { - return (e: PRChange) => { - if (e.changeType === "Addition") { - const apiVersion = getApiVersionFromSwaggerFile(e.filePath); - if (apiVersion) { - apiVersionSet.add(apiVersion); - } - const rpFolder = getRPFolderFromSwaggerFile(e.filePath); - if (rpFolder !== undefined) { - rpFolders.add(rpFolder); - } - console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); - } else if (e.changeType === "Update") { - const rpFolder = getRPFolderFromSwaggerFile(e.filePath); - if (rpFolder !== undefined) { - rpFolders.add(rpFolder); - } - } - }; - }; - - handlers.push({ SwaggerFile: createSwaggerFileHandler() }); - await processPrChanges(context, handlers); - - console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); - - const firstRPFolder = Array.from(rpFolders)[0]; - - console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); - - if (firstRPFolder === undefined) { - console.log("RP folder not found."); - return false; - } - - const targetBranchRPFolder = path.resolve(pr?.workingDir!, firstRPFolder); - - console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); - - const existingApiVersions = - getAllApiVersionFromRPFolder(targetBranchRPFolder); - - console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); - - for (const apiVersion of apiVersionSet) { - if (!existingApiVersions.includes(apiVersion)) { - console.log( - `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` - ); - isAddingNewApiVersion = true; - } - } - return isAddingNewApiVersion; -} +// todo: we need to populate this so that we can tell if it's a new APIVersion down stream +// async function isNewApiVersion(context: PRContext): Promise { +// const pr = await createPullRequestProperties( +// context, +// "pr-summary-new-api-version" +// ); +// const handlers: ChangeHandler[] = []; +// let isAddingNewApiVersion = false; +// const apiVersionSet = new Set(); + +// const rpFolders = new Set(); + +// const createSwaggerFileHandler = () => { +// return (e: PRChange) => { +// if (e.changeType === "Addition") { +// const apiVersion = getApiVersionFromSwaggerFile(e.filePath); +// if (apiVersion) { +// apiVersionSet.add(apiVersion); +// } +// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); +// if (rpFolder !== undefined) { +// rpFolders.add(rpFolder); +// } +// console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); +// } else if (e.changeType === "Update") { +// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); +// if (rpFolder !== undefined) { +// rpFolders.add(rpFolder); +// } +// } +// }; +// }; + +// handlers.push({ SwaggerFile: createSwaggerFileHandler() }); +// await processPrChanges(context, handlers); + +// console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); + +// const firstRPFolder = Array.from(rpFolders)[0]; + +// console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); + +// if (firstRPFolder === undefined) { +// console.log("RP folder not found."); +// return false; +// } + +// const targetBranchRPFolder = path.resolve(pr?.workingDir!, firstRPFolder); + +// console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + +// const existingApiVersions = +// getAllApiVersionFromRPFolder(targetBranchRPFolder); + +// console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + +// for (const apiVersion of apiVersionSet) { +// if (!existingApiVersions.includes(apiVersion)) { +// console.log( +// `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` +// ); +// isAddingNewApiVersion = true; +// } +// } +// return isAddingNewApiVersion; +// } async function processBreakingChangeLabels( prContext: PRContext, @@ -242,8 +258,6 @@ async function processBreakingChangeLabels( }>{ const prTargetsProductionBranch: boolean = checkPrTargetsProductionBranch(prContext); - const prKey: PRKey = PRKey.fromSourceRepoUri(prContext.sourceRepoUri, prContext.prNumber); - const breakingChangesLabelsFromOad = getBreakingChangesLabelsFromOad(); const versioningReviewRequiredLabel = new Label( @@ -256,8 +270,8 @@ async function processBreakingChangeLabels( labelContext.present ); - const versioningApprovalLabels = breakingChangesCheckType.SameVersion.approvalLabels.map(label => new Label(label, labelContext.present)); - const breakingChangeApprovalLabels = breakingChangesCheckType.CrossVersion.approvalLabels.map(label => new Label(label, labelContext.present)); + const versioningApprovalLabels = breakingChangesCheckType.SameVersion.approvalLabels.map((label: string) => new Label(label, labelContext.present)); + const breakingChangeApprovalLabels = breakingChangesCheckType.CrossVersion.approvalLabels.map((label: string) => new Label(label, labelContext.present)); // ----- Breaking changes label processing logic ----- // These lines implement the set of rules determining which of the breaking change @@ -346,7 +360,7 @@ async function processBreakingChangeLabels( // We are using this log as a metric to track and measure impact of the work on improving "breaking changes" tooling. Log statement added around 11/29/2023. // See: https://github.com/Azure/azure-sdk-tools/issues/7223#issuecomment-1839830834 // Note it duplicates the label "shouldBePresent" ruleset logic. - console.log(`processBreakingChangeLabels: PR: ${prKey.toUrl()}, targetBranch: ${prContext.targetBranch}. ` + console.log(`processBreakingChangeLabels: PR: ${`https://github.com/${prContext.owner}/${prContext.repo}/pull/${prContext.prNumber}`}, targetBranch: ${prContext.targetBranch}. ` + `The addition of 'BreakingChangesReviewRequired' or 'VersioningReviewRequired' labels has been prevented ` + `because we checked that the PR is not targeting a production branch.`); } @@ -380,14 +394,14 @@ function checkPrTargetsProductionBranch(prContext: PRContext): boolean { async function processTypeSpec( ctx: PRContext, labelContext: LabelContext, -) { +) : Promise{ console.log("ENTER definition processTypeSpec") const typeSpecLabel = new Label("TypeSpec", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. typeSpecLabel.shouldBePresent = false; const handlers: ChangeHandler[] = []; const typeSpecFileHandler = () => { - return (prChange: PRChange) => { + return (_: PRChange) => { // Note: this code will be executed if the PR has a diff on a TypeSpec file, // as defined in public/swagger-validation-common/src/context.ts/defaultFilePatterns/typespec typeSpecLabel.shouldBePresent = true; @@ -405,9 +419,10 @@ async function processTypeSpec( SwaggerFile: swaggerFileHandler(), }); await processPrChanges(ctx, handlers); - typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); console.log("RETURN definition processTypeSpec") + + return typeSpecLabel.shouldBePresent; } function isSwaggerGeneratedByTypeSpec(swaggerFilePath: string): boolean { @@ -688,12 +703,15 @@ async function processRPaaS( } function isRPSaaS(readmeFilePath: string) { - const readmeParser = new ReadmeParser(readmeFilePath); - const openapiSubtype = readmeParser.getGlobalConfigByName("openapi-subtype"); - console.log( - `readmeFilePath: ${readmeFilePath} openapi-subtype: ${openapiSubtype}` - ); - return openapiSubtype === "rpaas" || openapiSubtype === "providerHub"; + //todo: fix this + // const readmeParser = new ReadmeParser(readmeFilePath); + // const openapiSubtype = readmeParser.getGlobalConfigByName("openapi-subtype"); + // console.log( + // `readmeFilePath: ${readmeFilePath} openapi-subtype: ${openapiSubtype}` + // ); + // return openapiSubtype === "rpaas" || openapiSubtype === "providerHub"; + console.log(readmeFilePath); + return false; } async function processNewRPNamespace( @@ -830,41 +848,43 @@ async function processRpaasRpNotInPrivateRepoLabel( } } - if (!skip) { - const rpaasRPFolderList = await getRPaaSFolderList(); - const rpFolderNames: string[] = rpaasRPFolderList.map((f) => f.name); - - console.log(`RPaaS RP folder list: ${rpFolderNames}`); - - const handlers: ChangeHandler[] = []; - - const processPrChange = () => { - return (e: PRChange) => { - if (e.changeType === "Addition") { - const rpFolderName = getRPRootFolderName(e.filePath); - console.log( - `Processing processRpaasRpNotInPrivateRepoLabel rpFolderName: ${rpFolderName}` - ); - - if (rpFolderName === undefined) { - console.log(`RP folder is undefined for changed file path '${e.filePath}'.`); - return; - } - - if (!rpFolderNames.includes(rpFolderName)) { - console.log( - `This RP is RPSaaS RP but could not find rpFolderName: ${rpFolderName} in RPFolderNames: ${rpFolderNames}. ` - + `Label 'CI-RpaaSRPNotInPrivateRepo' should be present.` - ); - ciRpaasRPNotInPrivateRepoLabel.shouldBePresent = true; - } - } - }; - }; - - handlers.push({ SwaggerFile: processPrChange() }); - await processPrChanges(context, handlers); - } + // if (!skip) { + // // this is a request to get the list of RPaaS folders from azure-rest-api-specs-pr -> RPSaasMaster branch -> dump specification folder + // // names + // const rpaasRPFolderList = await getRPaaSFolderList(); + // const rpFolderNames: string[] = rpaasRPFolderList.map((f) => f.name); + + // console.log(`RPaaS RP folder list: ${rpFolderNames}`); + + // const handlers: ChangeHandler[] = []; + + // const processPrChange = () => { + // return (e: PRChange) => { + // if (e.changeType === "Addition") { + // const rpFolderName = getRPRootFolderName(e.filePath); + // console.log( + // `Processing processRpaasRpNotInPrivateRepoLabel rpFolderName: ${rpFolderName}` + // ); + + // if (rpFolderName === undefined) { + // console.log(`RP folder is undefined for changed file path '${e.filePath}'.`); + // return; + // } + + // if (!rpFolderNames.includes(rpFolderName)) { + // console.log( + // `This RP is RPSaaS RP but could not find rpFolderName: ${rpFolderName} in RPFolderNames: ${rpFolderNames}. ` + // + `Label 'CI-RpaaSRPNotInPrivateRepo' should be present.` + // ); + // ciRpaasRPNotInPrivateRepoLabel.shouldBePresent = true; + // } + // } + // }; + // }; + + // handlers.push({ SwaggerFile: processPrChange() }); + // await processPrChanges(context, handlers); + // } ciRpaasRPNotInPrivateRepoLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); console.log("RETURN definition processRpaasRpNotInPrivateRepoLabel") @@ -872,12 +892,3 @@ async function processRpaasRpNotInPrivateRepoLabel( return { ciRpaasRPNotInPrivateRepoLabelShouldBePresent: ciRpaasRPNotInPrivateRepoLabel.shouldBePresent } } -function warnIfLabelSetsIntersect(labelsToAdd: Set, labelsToRemove: Set) { - const intersection: string[] = _.intersection([...labelsToAdd], [...labelsToRemove]) - if (intersection.length > 0) { - console.warn("ASSERTION VIOLATION! The intersection of labelsToRemove and labelsToAdd is non-empty! " - + `labelsToAdd: [${[...labelsToAdd].join(", ")}]. ` - + `labelsToRemove: [${[...labelsToRemove].join(", ")}]. ` - + `intersection: [${intersection.join(", ")}].`) - } -} diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index be7899da600c..ce17e52d0b04 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -1,9 +1,9 @@ -import { join } from "path"; +// import { join } from "path"; -import { Readme } from "@azure-tools/specs-shared/readme"; +// import { Readme } from "@azure-tools/specs-shared/readme"; import { SpecModel } from "@azure-tools/specs-shared/spec-model"; -import { swagger, typespec, readme, example } from "@azure-tools/specs-shared/changed-files"; +import { swagger, typespec, example } from "@azure-tools/specs-shared/changed-files"; //readme, export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; export type ChangeTypes = "Addition" | "Deletion" | "Update"; @@ -24,10 +24,10 @@ export type ChangeHandler = { [key in FileTypes]?: (event: PRChange) => void; }; -type Pattern = { - includes: string[] | string; - excludes?: string[] | string; -}; +// type Pattern = { +// includes: string[] | string; +// excludes?: string[] | string; +// }; export type DiffResult = { additions?: T[] @@ -36,96 +36,96 @@ export type DiffResult = { } // do I need to add minimatch as a dependency? What does it do? -export const isPathMatch = ( - path: string, - pattern: string[] | string -): boolean => { - if (typeof pattern === "string") { - return minimatch(path, pattern, minimatchConfig); - } else { - return pattern.some((it) => minimatch(path, it, minimatchConfig)); - } -}; - -const isPatternMatch = (path: string, pattern: Pattern): boolean => { - if (pattern.excludes) { - if (isPathMatch(path, pattern.excludes)) { - return false; - } - } - return isPathMatch(path, pattern.includes); -}; - -export const enumerateFiles = async (path: string, pattern: Pattern) => { - let results: any[] = []; - if (await exists(path)) { - const runEnumerateFiles = ( - include: string, - excludes: string[] | string | undefined - ) => { - const absolutelyPath = join(path, include); - let ignores: string[] = []; - if (excludes) { - ignores = ignores.concat(excludes); - } - const files = glob.sync(absolutelyPath, { - ignore: ignores, - }); - return files; - }; - - if (Array.isArray(pattern.includes)) { - for (const pat of pattern.includes) { - results = results.concat(runEnumerateFiles(pat, pattern.excludes)); - } - } else { - results = runEnumerateFiles(pattern.includes, pattern.excludes); - } - } - return results; -}; - -const exists = async (path: string) => { - return existsSync(path); -}; - -const defaultFilePatterns = { - example: { - includes: "**/examples/**/*.json", - excludes: ["**/quickstart-templates/*.json", "**/schema/*.json", "**/scenarios/**/*.json","**/cadl/*.json"], - } as Pattern, - - swagger: { - includes: "**/*.json", - excludes: [ - "**/quickstart-templates/*.json", - "**/schema/*.json", - "**/scenarios/**/*.json", - "**/examples/**/*.json", - "**/package.json", - "**/package-lock.json", - "**/cadl/**/*.json" - ], - } as Pattern, - - cadl: { - includes: "**/*.cadl", - } as Pattern, - - typespec: { - includes: [ - "**/*.tsp", - // We do not include tspconfig.yml because by design it is invalid. The PR should have tspconfig.yaml instead. - // If a PR author adds tspconfig.yml the TypeSpec validation will report issue, blocking the PR from proceeding. - // Source code of this validation can be found at: - // https://github.com/Azure/azure-rest-api-specs/blob/b8c74fd80b415fa1ebb6fa787d454694c39e0fd5/eng/tools/typespec-validation/src/rules/folder-structure.ts#L27C27-L27C41 - // "**/*tspconfig.yml", - "**/*tspconfig.yaml" - ] - } as Pattern, - - readme: { includes: "**/readme.md" } as Pattern, -}; +// export const isPathMatch = ( +// path: string, +// pattern: string[] | string +// ): boolean => { +// if (typeof pattern === "string") { +// return minimatch(path, pattern, minimatchConfig); +// } else { +// return pattern.some((it) => minimatch(path, it, minimatchConfig)); +// } +// }; + +// const isPatternMatch = (path: string, pattern: Pattern): boolean => { +// if (pattern.excludes) { +// if (isPathMatch(path, pattern.excludes)) { +// return false; +// } +// } +// return isPathMatch(path, pattern.includes); +// }; + +// export const enumerateFiles = async (path: string, pattern: Pattern) => { +// let results: any[] = []; +// if (await exists(path)) { +// const runEnumerateFiles = ( +// include: string, +// excludes: string[] | string | undefined +// ) => { +// const absolutelyPath = join(path, include); +// let ignores: string[] = []; +// if (excludes) { +// ignores = ignores.concat(excludes); +// } +// const files = glob.sync(absolutelyPath, { +// ignore: ignores, +// }); +// return files; +// }; + +// if (Array.isArray(pattern.includes)) { +// for (const pat of pattern.includes) { +// results = results.concat(runEnumerateFiles(pat, pattern.excludes)); +// } +// } else { +// results = runEnumerateFiles(pattern.includes, pattern.excludes); +// } +// } +// return results; +// }; + +// const exists = async (path: string) => { +// return fs.existsSync(path); +// }; + +// const defaultFilePatterns = { +// example: { +// includes: "**/examples/**/*.json", +// excludes: ["**/quickstart-templates/*.json", "**/schema/*.json", "**/scenarios/**/*.json","**/cadl/*.json"], +// } as Pattern, + +// swagger: { +// includes: "**/*.json", +// excludes: [ +// "**/quickstart-templates/*.json", +// "**/schema/*.json", +// "**/scenarios/**/*.json", +// "**/examples/**/*.json", +// "**/package.json", +// "**/package-lock.json", +// "**/cadl/**/*.json" +// ], +// } as Pattern, + +// cadl: { +// includes: "**/*.cadl", +// } as Pattern, + +// typespec: { +// includes: [ +// "**/*.tsp", +// // We do not include tspconfig.yml because by design it is invalid. The PR should have tspconfig.yaml instead. +// // If a PR author adds tspconfig.yml the TypeSpec validation will report issue, blocking the PR from proceeding. +// // Source code of this validation can be found at: +// // https://github.com/Azure/azure-rest-api-specs/blob/b8c74fd80b415fa1ebb6fa787d454694c39e0fd5/eng/tools/typespec-validation/src/rules/folder-structure.ts#L27C27-L27C41 +// // "**/*tspconfig.yml", +// "**/*tspconfig.yaml" +// ] +// } as Pattern, + +// readme: { includes: "**/readme.md" } as Pattern, +// }; export type PRType = "resource-manager" | "data-plane"; @@ -164,12 +164,15 @@ export type LabelContext = { export type ImpactAssessment = { prType: string[], - suppressionsChanged: boolean, + suppressionReviewRequired: boolean, versioningReviewRequired: boolean, breakingChangeReviewRequired: boolean, + rpaasExceptionRequired: boolean, rpaasChange: boolean, newRP: boolean, rpaasRPMissing: boolean + typeSpecChanged: boolean + isDraft: boolean } export class Label { @@ -270,7 +273,9 @@ export type PRContextOptions = { targetBranch: string; sha: string; repo: string; + owner: string; prNumber: string; + prStatus: string; }; export class PRContext { @@ -286,7 +291,9 @@ export class PRContext { targetBranch: string; sha: string; repo: string; + owner: string; prNumber: string; + prStatus: string; labelContext: LabelContext; constructor( @@ -306,6 +313,8 @@ export class PRContext { this.repo = options.repo; this.prNumber = options.prNumber; this.fileList = options.fileList; + this.prStatus = options.prStatus; + this.owner = options.owner; } diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 9b3c40d99f71..5607c5a6a7d0 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -1,8 +1,22 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; //vi import path from "path"; -const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); +import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files"; + +// const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); + +describe("Check Changes", () => { + it("Should return true when the file has changes", async () => { + // const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); + const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); + + const changedFileDetails = await getChangedFilesStatuses({ cwd: sourceDirectory }); + + expect(changedFileDetails).toBeDefined(); + expect(changedFileDetails.total).toBeGreaterThan(0); + }); +}); // describe("invocation directory checks", () => { // it("Should return the same path when invoked from the root of a git repo.", async () => { From 6ee3555a275dd3069fe19f872905461cbbced973 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Wed, 16 Jul 2025 21:46:04 +0000 Subject: [PATCH 15/67] have a working integration test! time to fight through this --- .github/workflows/summarize-impact.yaml | 3 ++- eng/tools/summarize-impact/package.json | 2 +- eng/tools/summarize-impact/src/cli.ts | 8 +++--- eng/tools/summarize-impact/src/types.ts | 6 ++--- eng/tools/summarize-impact/test/cli.test.ts | 28 ++++++++++++++++++--- package-lock.json | 10 ++++++-- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 0a0d47f56b13..3fa644586304 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -52,7 +52,8 @@ jobs: --sha "${{ github.event.pull_request.head.sha }}" \ --repo "${{ github.repository }}" \ --owner "${{ github.repository_owner }}" \ - --labels "${{ steps.get-labels.outputs.labels }}" + --labels "${{ steps.get-labels.outputs.labels }}" \ + --isDraft ${{ github.event.pull_request.draft }} env: # We absolutely need to avoid OOM errors due to certain inherited types from openapi-alps NODE_OPTIONS: "--max-old-space-size=8192" diff --git a/eng/tools/summarize-impact/package.json b/eng/tools/summarize-impact/package.json index 6813ce3353ed..c9f749866ba5 100644 --- a/eng/tools/summarize-impact/package.json +++ b/eng/tools/summarize-impact/package.json @@ -11,7 +11,7 @@ "format": "prettier . --ignore-path ../.prettierignore --write", "format:check": "prettier . --ignore-path ../.prettierignore --check", "format:check:ci": "prettier . --ignore-path ../.prettierignore --check --log-level debug", - "test": "vitest", + "test": "vitest --run", "test:ci": "vitest run --coverage --reporter=verbose" }, "dependencies": { diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 853abda89852..2a23eb07b0bb 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -76,8 +76,8 @@ export async function main() { short: "l", multiple: false }, - prStatus: { - type: "string", + isDraft: { + type: "boolean", multiple: false } }, @@ -99,7 +99,7 @@ export async function main() { const owner = opts.owner as string; const prNumber = opts.number as string; const existingLabels = (opts.labels as string).split(",").map((l) => l.trim()); - const prStatus = opts.prStatus as string; + const isDraft = opts.prStaisDrafttus as boolean; const labelContext: LabelContext = { present: new Set(existingLabels), @@ -119,7 +119,7 @@ export async function main() { prNumber, owner, fileList, - prStatus + isDraft }); evaluateImpact(prContext, labelContext); diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index ce17e52d0b04..e5f0c5cc1f57 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -275,7 +275,7 @@ export type PRContextOptions = { repo: string; owner: string; prNumber: string; - prStatus: string; + isDraft: boolean; }; export class PRContext { @@ -293,7 +293,7 @@ export class PRContext { repo: string; owner: string; prNumber: string; - prStatus: string; + isDraft: boolean; labelContext: LabelContext; constructor( @@ -313,7 +313,7 @@ export class PRContext { this.repo = options.repo; this.prNumber = options.prNumber; this.fileList = options.fileList; - this.prStatus = options.prStatus; + this.isDraft = options.isDraft; this.owner = options.owner; } diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 5607c5a6a7d0..b3e0e48738ce 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -3,18 +3,40 @@ import { describe, it, expect } from "vitest"; //vi import path from "path"; import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files"; +import { PRContext, LabelContext } from "../src/types.js"; +import { evaluateImpact } from "../src/runner.js"; // const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); describe("Check Changes", () => { it("Should return true when the file has changes", async () => { - // const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); + const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); - const changedFileDetails = await getChangedFilesStatuses({ cwd: sourceDirectory }); + const changedFileDetails = await getChangedFilesStatuses({ cwd: sourceDirectory, baseCommitish: "origin/main" }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set() + }; + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", + sourceBranch: "dev/nandiniy/DTLTypeSpec", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35346", + owner: "Azure", + fileList: changedFileDetails, + isDraft: true + }); + + const result = await evaluateImpact(prContext, labelContext); + + + expect(result).toBeDefined(); expect(changedFileDetails).toBeDefined(); - expect(changedFileDetails.total).toBeGreaterThan(0); + expect(changedFileDetails.total).toEqual(293); }); }); diff --git a/package-lock.json b/package-lock.json index bca095dbaec7..65b364e2d172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -466,17 +466,23 @@ } }, "eng/tools/summarize-impact": { - "name": "@azure-tools/oav-runner", + "name": "@azure-tools/summarize-impact", "dev": true, "dependencies": { "@azure-tools/specs-shared": "file:../../../.github/shared", + "@ts-common/commonmark-to-markdown": "^2.0.2", + "commonmark": "^0.29.0", + "glob": "latest", "js-yaml": "^4.1.0", + "lodash": "^4.17.20", "simple-git": "^3.27.0" }, "bin": { - "oav-runner": "cmd/oav-runner.js" + "summarize-impact": "cmd/summarize-impact.js" }, "devDependencies": { + "@types/commonmark": "^0.27.4", + "@types/lodash": "^4.14.161", "@types/node": "^20.0.0", "prettier": "~3.5.3", "typescript": "~5.8.2", From 485001abebb917afbf6c589dc13ff9e3d216b908 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Wed, 16 Jul 2025 21:59:20 +0000 Subject: [PATCH 16/67] honhonor new arg --- eng/tools/summarize-impact/src/runner.ts | 2 +- eng/tools/summarize-impact/test/cli.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index 47e1a0a0f72b..054b22eae8d2 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -90,7 +90,7 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont rpaasRPMissing: ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, rpaasExceptionRequired: rpaasExceptionLabelShouldBePresent, typeSpecChanged: typeSpecLabelShouldBePresent, - isDraft: context.prStatus === "draft", + isDraft: context.isDraft, }; } diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index b3e0e48738ce..fc88d7b60b4f 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -28,12 +28,11 @@ describe("Check Changes", () => { prNumber: "35346", owner: "Azure", fileList: changedFileDetails, - isDraft: true + isDraft: false }); const result = await evaluateImpact(prContext, labelContext); - expect(result).toBeDefined(); expect(changedFileDetails).toBeDefined(); expect(changedFileDetails.total).toEqual(293); From d9794b9800d24f3ff7a8be44d9096de316a46aa4 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 17 Jul 2025 01:07:50 +0000 Subject: [PATCH 17/67] really clean up the codebase. starting to lean harder into the readme parsing. --- eng/tools/summarize-impact/src/runner.ts | 182 +++++++++++--------- eng/tools/summarize-impact/src/types.ts | 9 +- eng/tools/summarize-impact/test/cli.test.ts | 26 --- 3 files changed, 105 insertions(+), 112 deletions(-) diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index 054b22eae8d2..5c10d70ce53c 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -15,10 +15,81 @@ import { breakingChangesCheckType } from "@azure-tools/specs-shared/breaking-cha // import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; //getChangedFiles, import { FileTypes, ChangeTypes, PRChange, ChangeHandler, PRType, ImpactAssessment, LabelContext, PRContext, Label, DiffResult, ReadmeTag } from "./types.js" +import { Readme } from "@azure-tools/specs-shared/readme"; export declare const breakingChangeLabelVarName = "breakingChangeVar"; export declare const crossVersionBreakingChangeLabelVarName = "crossVersionBreakingChangeVar"; +// todo: we need to populate this so that we can tell if it's a new APIVersion down stream +export async function isNewApiVersion(context: PRContext): Promise { + // const pr = await createPullRequestProperties( + // context, + // "pr-summary-new-api-version" + // ); + // const handlers: ChangeHandler[] = []; + // let isAddingNewApiVersion = false; + // const apiVersionSet = new Set(); + + // const rpFolders = new Set(); + + // const createSwaggerFileHandler = () => { + // return (e: PRChange) => { + // if (e.changeType === "Addition") { + // const apiVersion = getApiVersionFromSwaggerFile(e.filePath); + // if (apiVersion) { + // apiVersionSet.add(apiVersion); + // } + // const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + // if (rpFolder !== undefined) { + // rpFolders.add(rpFolder); + // } + // console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); + // } else if (e.changeType === "Update") { + // const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + // if (rpFolder !== undefined) { + // rpFolders.add(rpFolder); + // } + // } + // }; + // }; + + // handlers.push({ SwaggerFile: createSwaggerFileHandler() }); + // await processPrChanges(context, handlers); + + // console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); + + // const firstRPFolder = Array.from(rpFolders)[0]; + + // console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); + + // if (firstRPFolder === undefined) { + // console.log("RP folder not found."); + // return false; + // } + + // const targetBranchRPFolder = path.resolve(pr?.workingDir!, firstRPFolder); + + // console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + + // const existingApiVersions = + // getAllApiVersionFromRPFolder(targetBranchRPFolder); + + // console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + + // for (const apiVersion of apiVersionSet) { + // if (!existingApiVersions.includes(apiVersion)) { + // console.log( + // `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` + // ); + // isAddingNewApiVersion = true; + // } + // } + // return isAddingNewApiVersion; + console.log(`${context.owner}/${context.repo} isNewApiVersion: false`); + return false; +} + + export async function evaluateImpact(context: PRContext, labelContext: LabelContext): Promise { const typeSpecLabelShouldBePresent = await processTypeSpec(context, labelContext); @@ -77,9 +148,8 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont rpaasLabelShouldBePresent ); + const newApiVersion = await isNewApiVersion(context); - // todo: - console.log(ciRpaasRPNotInPrivateRepoLabelShouldBePresent) return { prType: [], suppressionReviewRequired: labelContext.toAdd.has("suppressionsReviewRequired"), @@ -88,8 +158,11 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont rpaasChange: rpaasLabelShouldBePresent, newRP: newRPNamespaceLabelShouldBePresent, rpaasRPMissing: ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasRpNotInPrivateRepo: ciRpaasRPNotInPrivateRepoLabelShouldBePresent, + resourceManagerRequired: resourceManagerLabelShouldBePresent, rpaasExceptionRequired: rpaasExceptionLabelShouldBePresent, typeSpecChanged: typeSpecLabelShouldBePresent, + isNewApiVersion: newApiVersion, isDraft: context.isDraft, }; } @@ -183,73 +256,6 @@ export const getResourceProviderFromFilePath = ( return undefined; }; -// todo: we need to populate this so that we can tell if it's a new APIVersion down stream -// async function isNewApiVersion(context: PRContext): Promise { -// const pr = await createPullRequestProperties( -// context, -// "pr-summary-new-api-version" -// ); -// const handlers: ChangeHandler[] = []; -// let isAddingNewApiVersion = false; -// const apiVersionSet = new Set(); - -// const rpFolders = new Set(); - -// const createSwaggerFileHandler = () => { -// return (e: PRChange) => { -// if (e.changeType === "Addition") { -// const apiVersion = getApiVersionFromSwaggerFile(e.filePath); -// if (apiVersion) { -// apiVersionSet.add(apiVersion); -// } -// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); -// if (rpFolder !== undefined) { -// rpFolders.add(rpFolder); -// } -// console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); -// } else if (e.changeType === "Update") { -// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); -// if (rpFolder !== undefined) { -// rpFolders.add(rpFolder); -// } -// } -// }; -// }; - -// handlers.push({ SwaggerFile: createSwaggerFileHandler() }); -// await processPrChanges(context, handlers); - -// console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); - -// const firstRPFolder = Array.from(rpFolders)[0]; - -// console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); - -// if (firstRPFolder === undefined) { -// console.log("RP folder not found."); -// return false; -// } - -// const targetBranchRPFolder = path.resolve(pr?.workingDir!, firstRPFolder); - -// console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); - -// const existingApiVersions = -// getAllApiVersionFromRPFolder(targetBranchRPFolder); - -// console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); - -// for (const apiVersion of apiVersionSet) { -// if (!existingApiVersions.includes(apiVersion)) { -// console.log( -// `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` -// ); -// isAddingNewApiVersion = true; -// } -// } -// return isAddingNewApiVersion; -// } - async function processBreakingChangeLabels( prContext: PRContext, labelContext: LabelContext): Promise<{ @@ -450,6 +456,22 @@ export async function processPrChanges( console.log("RETURN definition processPrChanges") } +export async function processPRChangesAsync( + ctx: PRContext, + Handlers: ChangeHandler[] +) { + console.log("ENTER definition processPRChangesAsync") + const prChanges = await getPRChanges(ctx); + prChanges.forEach((prChange) => { + Handlers.forEach(async (handler) => { + if (prChange.fileType in handler) { + await handler?.[prChange.fileType]?.(prChange); + } + }); + }); + console.log("RETURN definition processPRChangesAsync") +} + export async function getPRChanges(ctx: PRContext): Promise { console.log("ENTER definition getPRChanges") const results: PRChange[] = []; @@ -686,8 +708,8 @@ async function processRPaaS( const handlers: ChangeHandler[] = []; const createReadmeFileHandler = () => { - return (e: PRChange) => { - if (e.changeType !== "Deletion" && isRPSaaS(e.filePath)) { + return async (e: PRChange) => { + if (e.changeType !== "Deletion" && await isRPSaaS(e.filePath)) { rpaasLabel.shouldBePresent = true; } }; @@ -695,23 +717,17 @@ async function processRPaaS( handlers.push({ ReadmeFile: createReadmeFileHandler(), }); - await processPrChanges(context, handlers); + await processPRChangesAsync(context, handlers); rpaasLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); console.log("RETURN definition processRPaaS") return { rpaasLabelShouldBePresent: rpaasLabel.shouldBePresent } } -function isRPSaaS(readmeFilePath: string) { - //todo: fix this - // const readmeParser = new ReadmeParser(readmeFilePath); - // const openapiSubtype = readmeParser.getGlobalConfigByName("openapi-subtype"); - // console.log( - // `readmeFilePath: ${readmeFilePath} openapi-subtype: ${openapiSubtype}` - // ); - // return openapiSubtype === "rpaas" || openapiSubtype === "providerHub"; - console.log(readmeFilePath); - return false; +async function isRPSaaS(readmeFilePath: string) { + const config: any = await new Readme(readmeFilePath).getGlobalConfig(); + + return config["openapi-subtype"] === "rpaas" || config["openapi-subtype"] === "providerHub"; } async function processNewRPNamespace( @@ -847,7 +863,7 @@ async function processRpaasRpNotInPrivateRepoLabel( skip = true; } } - + // todo: retrieve the list and populate this value properly. // if (!skip) { // // this is a request to get the list of RPaaS folders from azure-rest-api-specs-pr -> RPSaasMaster branch -> dump specification folder // // names diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index e5f0c5cc1f57..3a02c7c6ecc0 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -21,7 +21,7 @@ export type ReadmeTag = { }; export type ChangeHandler = { - [key in FileTypes]?: (event: PRChange) => void; + [key in FileTypes]?: (event: PRChange) => void | Promise; }; // type Pattern = { @@ -164,14 +164,17 @@ export type LabelContext = { export type ImpactAssessment = { prType: string[], + resourceManagerRequired: boolean, suppressionReviewRequired: boolean, versioningReviewRequired: boolean, breakingChangeReviewRequired: boolean, + isNewApiVersion: boolean, rpaasExceptionRequired: boolean, + rpaasRpNotInPrivateRepo: boolean, rpaasChange: boolean, newRP: boolean, - rpaasRPMissing: boolean - typeSpecChanged: boolean + rpaasRPMissing: boolean, + typeSpecChanged: boolean, isDraft: boolean } diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index fc88d7b60b4f..5750734b1578 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -38,29 +38,3 @@ describe("Check Changes", () => { expect(changedFileDetails.total).toEqual(293); }); }); - -// describe("invocation directory checks", () => { -// it("Should return the same path when invoked from the root of a git repo.", async () => { -// const result = await getRootFolder(REPOROOT); -// expect(result).toBe(REPOROOT); -// }); - -// it("Should return a higher path when invoked from a path deep in a git repo.", async () => { -// const result = await getRootFolder(path.join(REPOROOT, "eng", "tools", "oav-runner")); -// expect(result).toBe(REPOROOT); -// }); - -// it("Should exit with error when invoked outside of a git directory.", async () => { -// const pathOutsideRepo = path.resolve(path.join(REPOROOT, "..")); - -// const exitMock = vi -// .spyOn(process, "exit") -// .mockImplementation((code?: string | number | null | undefined) => { -// throw new Error(`Exit ${code}`); -// }); - -// await expect(getRootFolder(pathOutsideRepo)).rejects.toThrow("Exit 1"); - -// exitMock.mockRestore(); -// }); -// }); From 942e2ed0ac391659245fa501b3e3c1771d03a41f Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 17 Jul 2025 02:29:00 +0000 Subject: [PATCH 18/67] processSuppression isn't working the way it should --- eng/tools/summarize-impact/src/runner.ts | 108 ++++++++++---------- eng/tools/summarize-impact/src/types.ts | 5 +- eng/tools/summarize-impact/test/cli.test.ts | 5 + 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index 5c10d70ce53c..ff6ee773a104 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -22,76 +22,70 @@ export declare const crossVersionBreakingChangeLabelVarName = "crossVersionBreak // todo: we need to populate this so that we can tell if it's a new APIVersion down stream export async function isNewApiVersion(context: PRContext): Promise { - // const pr = await createPullRequestProperties( - // context, - // "pr-summary-new-api-version" - // ); - // const handlers: ChangeHandler[] = []; - // let isAddingNewApiVersion = false; - // const apiVersionSet = new Set(); - - // const rpFolders = new Set(); - - // const createSwaggerFileHandler = () => { - // return (e: PRChange) => { - // if (e.changeType === "Addition") { - // const apiVersion = getApiVersionFromSwaggerFile(e.filePath); - // if (apiVersion) { - // apiVersionSet.add(apiVersion); - // } - // const rpFolder = getRPFolderFromSwaggerFile(e.filePath); - // if (rpFolder !== undefined) { - // rpFolders.add(rpFolder); - // } - // console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); - // } else if (e.changeType === "Update") { - // const rpFolder = getRPFolderFromSwaggerFile(e.filePath); - // if (rpFolder !== undefined) { - // rpFolders.add(rpFolder); - // } - // } - // }; - // }; - // handlers.push({ SwaggerFile: createSwaggerFileHandler() }); - // await processPrChanges(context, handlers); + const handlers: ChangeHandler[] = []; + let isAddingNewApiVersion = false; + const apiVersionSet = new Set(); - // console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); + const rpFolders = new Set(); - // const firstRPFolder = Array.from(rpFolders)[0]; + const createSwaggerFileHandler = () => { + return (e: PRChange) => { + if (e.changeType === "Addition") { + const apiVersion = getApiVersionFromSwaggerFile(e.filePath); + if (apiVersion) { + apiVersionSet.add(apiVersion); + } + const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + if (rpFolder !== undefined) { + rpFolders.add(rpFolder); + } + console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); + } else if (e.changeType === "Update") { + const rpFolder = getRPFolderFromSwaggerFile(e.filePath); + if (rpFolder !== undefined) { + rpFolders.add(rpFolder); + } + } + }; + }; - // console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); + handlers.push({ SwaggerFile: createSwaggerFileHandler() }); + await processPrChanges(context, handlers); - // if (firstRPFolder === undefined) { - // console.log("RP folder not found."); - // return false; - // } + console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); - // const targetBranchRPFolder = path.resolve(pr?.workingDir!, firstRPFolder); + const firstRPFolder = Array.from(rpFolders)[0]; - // console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); - // const existingApiVersions = - // getAllApiVersionFromRPFolder(targetBranchRPFolder); + if (firstRPFolder === undefined) { + console.log("RP folder not found."); + return false; + } - // console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + const targetBranchRPFolder = path.resolve(context.targetDirectory, firstRPFolder); - // for (const apiVersion of apiVersionSet) { - // if (!existingApiVersions.includes(apiVersion)) { - // console.log( - // `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` - // ); - // isAddingNewApiVersion = true; - // } - // } - // return isAddingNewApiVersion; - console.log(`${context.owner}/${context.repo} isNewApiVersion: false`); - return false; + console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + + const existingApiVersions = + getAllApiVersionFromRPFolder(targetBranchRPFolder); + + console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + + for (const apiVersion of apiVersionSet) { + if (!existingApiVersions.includes(apiVersion)) { + console.log( + `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` + ); + isAddingNewApiVersion = true; + } + } + return isAddingNewApiVersion; } export async function evaluateImpact(context: PRContext, labelContext: LabelContext): Promise { - const typeSpecLabelShouldBePresent = await processTypeSpec(context, labelContext); // examine changed files. if changedpaths includes data-plane, add "data-plane" @@ -164,6 +158,7 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont typeSpecChanged: typeSpecLabelShouldBePresent, isNewApiVersion: newApiVersion, isDraft: context.isDraft, + labelContext: labelContext }; } @@ -863,6 +858,7 @@ async function processRpaasRpNotInPrivateRepoLabel( skip = true; } } + // todo: retrieve the list and populate this value properly. // if (!skip) { // // this is a request to get the list of RPaaS folders from azure-rest-api-specs-pr -> RPSaasMaster branch -> dump specification folder diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index 3a02c7c6ecc0..2e81a62c5d57 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -175,7 +175,8 @@ export type ImpactAssessment = { newRP: boolean, rpaasRPMissing: boolean, typeSpecChanged: boolean, - isDraft: boolean + isDraft: boolean, + labelContext: LabelContext } export class Label { @@ -384,13 +385,13 @@ export class PRContext { } async getReadmeDiffs(): Promise> { + // todo: properly implement this // get all the readme files // generate the diffresult from it. // Readme // console.log(`ahahahah readme tags are ${rmtags}`) - // console.log("ENTER definition LocalDirContext.getReadmeDiffs") // if (this.readmeDiffs) { // console.log("RETURN definition LocalDirContext.getReadmeDiffs - early return") diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 5750734b1578..b34b13bea25b 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -34,6 +34,11 @@ describe("Check Changes", () => { const result = await evaluateImpact(prContext, labelContext); expect(result).toBeDefined(); + expect(result.typeSpecChanged).toBeTruthy(); + + expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); expect(changedFileDetails).toBeDefined(); expect(changedFileDetails.total).toEqual(293); }); From 9c71c672ea72cbe98d909c19f7f192314f43a811 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 17 Jul 2025 20:46:29 +0000 Subject: [PATCH 19/67] update the checks for necessary changes --- .github/workflows/summarize-checks.yaml | 1 + .github/workflows/summarize-impact.yaml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/summarize-checks.yaml b/.github/workflows/summarize-checks.yaml index 1cffd04eb3cf..d884fd4bb416 100644 --- a/.github/workflows/summarize-checks.yaml +++ b/.github/workflows/summarize-checks.yaml @@ -9,6 +9,7 @@ on: - "Swagger Avocado - Set Status" - "Swagger LintDiff - Set Status" - "SDK Validation Status" + - ["\\[TEST-IGNORE\\] Summarize PR Impact"] types: - completed pull_request_target: # when a PR is labeled. NOT pull_request, because that would run on the PR branch, not the base branch. diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 3fa644586304..b84d0567b106 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -1,4 +1,4 @@ -name: "Summarize PR Impact" +name: "[TEST-IGNORE] Summarize PR Impact" on: pull_request @@ -8,7 +8,7 @@ permissions: jobs: impact: - name: "Summarize PR Impact" + name: "[TEST-IGNORE] Summarize PR Impact" runs-on: ubuntu-24.04 steps: From e49d364c136f60b428e6668c5ab726dbde82b6e7 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 18 Jul 2025 02:05:51 +0000 Subject: [PATCH 20/67] saving a checkpoint. can I trust the suppression result now? --- .../src/summarize-checks/summarize-checks.js | 905 +++++++++++------- eng/tools/summarize-impact/package.json | 2 + eng/tools/summarize-impact/src/runner.ts | 2 +- eng/tools/summarize-impact/src/types.ts | 245 ++++- 4 files changed, 770 insertions(+), 384 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index f57bd0c61e4f..68215c12acb7 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -52,6 +52,71 @@ import { toASCII } from "punycode"; * @property {CheckMetadata} checkInfo */ +/** + * @typedef {Object} WorkflowRunArtifact + * @property {string} name + * @property {number} id + * @property {string} url + * @property {string} archive_download_url + */ + +/** + * @typedef {Object} WorkflowRunInfo + * @property {string} name + * @property {number} id + * @property {number} databaseId + * @property {string} url + * @property {number} workflowId + * @property {string} status + * @property {string} conclusion + * @property {string} createdAt + * @property {string} updatedAt + */ + +/** + * @typedef {Object} GraphQLCheckRun + * @property {string} name + * @property {string} status + * @property {string} conclusion + * @property {boolean} isRequired + */ + +/** + * @typedef {Object} GraphQLCheckSuite + * @property {GraphQLCheckRun[]} nodes + * @property {WorkflowRunInfo} workflowRun + */ + +/** + * @typedef {Object} GraphQLCheckSuites + * @property {GraphQLCheckSuite[]} nodes + */ + +/** + * @typedef {Object} GraphQLCommit + * @property {GraphQLCheckSuites} checkSuites + */ + +/** + * @typedef {Object} GraphQLResource + * @property {GraphQLCheckSuites} checkSuites + */ + +/** + * @typedef {Object} GraphQLResponse + * @property {GraphQLResource} resource + * @property {Object} repository + * @property {Object} repository.object + * @property {Object} repository.object.associatedPullRequests + * @property {Array<{number: number, commits: {nodes: Array<{commit: GraphQLCommit}>}}>} repository.object.associatedPullRequests.nodes + * @property {Object} rateLimit + * @property {number} rateLimit.limit + * @property {number} rateLimit.cost + * @property {number} rateLimit.used + * @property {number} rateLimit.remaining + * @property {string} rateLimit.resetAt + */ + /** * @typedef {import("./labelling.js").RequiredLabelRule} RequiredLabelRule */ @@ -325,8 +390,8 @@ export async function summarizeChecksImpl( } } - /** @type {[CheckRunData[], CheckRunData[]]} */ - const [requiredCheckRuns, fyiCheckRuns] = await getCheckRunTuple( + /** @type {[CheckRunData[], CheckRunData[], WorkflowRunInfo[]]} */ + const [requiredCheckRuns, fyiCheckRuns, workflowRuns] = await getCheckRunTuple( github, core, owner, @@ -425,6 +490,38 @@ function getGraphQLQuery(owner, repo, sha, prNumber) { } } } + repository(owner: "${owner}", name: "${repo}") { + object(expression: "${sha}") { + ... on Commit { + associatedPullRequests(first: 10) { + nodes { + number + commits(last: 1) { + nodes { + commit { + checkSuites(first: 20) { + nodes { + workflowRun { + id + databaseId + url + workflowId + name + status + conclusion + createdAt + updatedAt + } + } + } + } + } + } + } + } + } + } + } rateLimit { limit cost @@ -560,345 +657,345 @@ export function processArmReviewLabels( } } -/** -This function determines which labels of the ARM review should -be applied to given PR. It adds and removes the labels as appropriate. - -This function does the following, **among other things**: - -- Adds the "ARMReview" label if all of the following conditions hold: - - The processed PR "isReleaseBranch" or "isShiftLeftPRWithRPSaaSDev" - - The PR is not a draft, as determined by "isDraftPR" - - The PR is labelled with "resource-manager" label, meaning it pertains - to ARM, as previously determined by the "isManagementPR" function, - called from the "getPRType" function. - -- Calls the "processARMReviewWorkflowLabels" function if "ARMReview" label applies. -*/ -// todo: refactor to take context: PRContext as input instead of IValidatorContext. -// All downstream usage appears to be using "context.contextConfig() as PRContext". -async function processARMReview( - context: IValidatorContext, - owner: string, - repo: string, - issue_number: number, - labelContext: LabelContext, - resourceManagerLabelShouldBePresent: boolean, - versioningReviewRequiredLabelShouldBePresent: boolean, - breakingChangeReviewRequiredLabelShouldBePresent: boolean, - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, - rpaasExceptionLabelShouldBePresent: boolean, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean, - isDraft: boolean, -): Promise<{ armReviewLabelShouldBePresent: boolean }> { - console.log("ENTER definition processARMReview") - - const armReviewLabel = new Label("ARMReview", labelContext.present); - // By default this label should not be present. We may determine later in this function that it should be present after all. - armReviewLabel.shouldBePresent = false; - - const newApiVersionLabel = new Label("new-api-version", labelContext.present); - // By default this label should not be present. We may determine later in this function that it should be present after all. - newApiVersionLabel.shouldBePresent = false; - - const branch = (context.contextConfig() as PRContext).targetBranch - const prTitle = await getPrTitle(owner, repo, prNumber) - const isReleaseBranchVal: boolean = isReleaseBranch(branch) - const isShiftLeftPRWithRPSaaSDevVal: boolean = isShiftLeftPRWithRPSaaSDev(prTitle, branch) - const isBranchInScopeOfSpecReview: boolean = isReleaseBranchVal || isShiftLeftPRWithRPSaaSDevVal - let isNewApiVersionVal: boolean | "not_computed" = "not_computed" - let isMissingBaseCommit: boolean | "not_computed" = "not_computed" - - // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic - // determines which kind of review exactly we need. - let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview - if (specReviewApplies) { - isNewApiVersionVal = await isNewApiVersion(context) - if (isNewApiVersionVal) { - // Note that in case of data-plane PRs, the addition of this label will result - // in API stewardship board review being required. - // See requiredLabelsRules.ts. - newApiVersionLabel.shouldBePresent = true; - } - - armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent - await processARMReviewWorkflowLabels( - labelContext, - armReviewLabel.shouldBePresent, - versioningReviewRequiredLabelShouldBePresent, - breakingChangeReviewRequiredLabelShouldBePresent, - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, - rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent) - } - - newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) - armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) - - console.log(`RETURN definition processARMReview. ` - + `url: ${new PRKey(owner, repo, prNumber).toUrl()}, owner: ${owner}, repo: ${repo}, pr: ${prNumber}, branch: ${branch}, ` - + `isReleaseBranch: ${isReleaseBranchVal}, ` - + `isShiftLeftPRWithRPSaaSDev: ${isShiftLeftPRWithRPSaaSDevVal}, ` - + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` - + `isNewApiVersion: ${isNewApiVersionVal}, ` - + `isMissingBaseCommit: ${isMissingBaseCommit}, ` - + `isDraft: ${isDraft}, ` - + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` - + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) - - return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } -} - -function isReleaseBranch(branchName: string) { - const branchRegex = [/main/, /RPSaaSMaster/, /release*/, /ARMCoreRPDev/]; - return branchRegex.some((b) => b.test(branchName)); -} - -// For shift-left review process, if the PR target branch is RPSaaSDev, it still need ARM review -function isShiftLeftPRWithRPSaaSDev( - prTitle: string, - branch: string -): boolean { - const shiftLeftPRTitle = "[AutoSync]"; - const isShiftLeftPR = prTitle.includes(shiftLeftPRTitle) && branch === "RPSaaSDev"; - if (isShiftLeftPR) { - console.log( - `The PR is shift-left PR with RPSaaSDev branch. it need ARM review` - ); - } - return isShiftLeftPR; -} - -async function isNewApiVersion(context: IValidatorContext): Promise { - const pr = await createPullRequestProperties( - context, - "pr-summary-new-api-version" - ); - const handlers: ChangeHandler[] = []; - let isAddingNewApiVersion = false; - const apiVersionSet = new Set(); - - const rpFolders = new Set(); - - const createSwaggerFileHandler = () => { - return (e: PRChange) => { - if (e.changeType === "Addition") { - const apiVersion = getApiVersionFromSwaggerFile(e.filePath); - if (apiVersion) { - apiVersionSet.add(apiVersion); - } - const rpFolder = getRPFolderFromSwaggerFile(e.filePath); - if (rpFolder !== undefined) { - rpFolders.add(rpFolder); - } - console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); - } else if (e.changeType === "Update") { - const rpFolder = getRPFolderFromSwaggerFile(e.filePath); - if (rpFolder !== undefined) { - rpFolders.add(rpFolder); - } - } - }; - }; - - handlers.push({ SwaggerFile: createSwaggerFileHandler() }); - await processPrChanges(context, handlers); - - console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); - - const firstRPFolder = Array.from(rpFolders)[0]; - - console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); - - if (firstRPFolder === undefined) { - console.log("RP folder not found."); - return false; - } - - const targetBranchRPFolder = resolve(pr?.workingDir!, firstRPFolder); - - console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); - - const existingApiVersions = - getAllApiVersionFromRPFolder(targetBranchRPFolder); - - console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); - - for (const apiVersion of apiVersionSet) { - if (!existingApiVersions.includes(apiVersion)) { - console.log( - `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` - ); - isAddingNewApiVersion = true; - } - } - return isAddingNewApiVersion; -} - -/** -CODESYNC: -- requiredLabelsRules.ts / requiredLabelsRules -- https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml - -This function determines which label from the ARM review workflow labels -should be present on the PR. It adds and removes the labels as appropriate. - -In other words, this function captures the -ARM review workflow label processing logic. - -To be exact, this function executes if and only if the PR in question -has been determined to have the "ARMReview" label, denoting given PR -is in scope for ARM review. - -The implementation of this function is the source of truth specifying the -desired behavior. - -To understand this implementation, the most important constraint to keep in mind -is that if "ARMReview" label is present, then exactly one of the following -labels must be present: - -- NotReadyForARMReview -- WaitForARMFeedback -- ARMChangesRequested -- ARMSignedOff - -Note that another important place in this codebase where ARM review workflow -labels are being removed or added to a PR is pipelineBotOnPRLabelEvent.ts. -*/ -async function processARMReviewWorkflowLabels( - labelContext: LabelContext, - armReviewLabelShouldBePresent: boolean, - versioningReviewRequiredLabelShouldBePresent: boolean, - breakingChangeReviewRequiredLabelShouldBePresent: boolean, - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, - rpaasExceptionLabelShouldBePresent: boolean, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean -): Promise { - console.log("ENTER definition processARMReviewWorkflowLabels"); - - const notReadyForArmReviewLabel = new Label( - "NotReadyForARMReview", - labelContext.present); - - const waitForArmFeedbackLabel = new Label( - "WaitForARMFeedback", - labelContext.present - ); - - const armChangesRequestedLabel = new Label( - "ARMChangesRequested", - labelContext.present - ); - - const armSignedOffLabel = new Label("ARMSignedOff", labelContext.present); - - const blockedOnVersioningPolicy = getBlockedOnVersioningPolicy( - labelContext, - breakingChangeReviewRequiredLabelShouldBePresent, - versioningReviewRequiredLabelShouldBePresent - ); - - const blockedOnRpaas = getBlockedOnRpaas( - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, - rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent - ); - - const blocked = blockedOnVersioningPolicy || blockedOnRpaas; - - // If given PR is in scope of ARM review and it is blocked for any reason, - // the "NotReadyForARMReview" label should be present, to the exclusion - // of all other ARM review workflow labels. - notReadyForArmReviewLabel.shouldBePresent = - armReviewLabelShouldBePresent && blocked; - - // If given PR is in scope of ARM review and the review is not blocked, - // then "ARMSignedOff" label should remain present on the PR if it was - // already present. This means that labels "ARMChangesRequested" - // and "WaitForARMFeedback" are invalid and will be removed by automation - // in presence of "ARMSignedOff". - armSignedOffLabel.shouldBePresent = - armReviewLabelShouldBePresent && - !blocked && - armSignedOffLabel.present; - - // If given PR is in scope of ARM review and the review is not blocked and - // not signed-off, then the label "ARMChangesRequested" should remain present - // if it was already present. This means that labels "WaitForARMFeedback" - // is invalid and will be removed by automation in presence of - // "WaitForARMFeedback". - armChangesRequestedLabel.shouldBePresent = - armReviewLabelShouldBePresent && - !blocked && - !armSignedOffLabel.shouldBePresent && - armChangesRequestedLabel.present; - - // If given PR is in scope of ARM review and the review is not blocked and - // not signed-off, and ARM reviewer didn't request any changes, - // then the label "WaitForARMFeedback" should be present on the PR, whether - // it was present before or not. - waitForArmFeedbackLabel.shouldBePresent = - armReviewLabelShouldBePresent && - !blocked && - !armSignedOffLabel.shouldBePresent && - !armChangesRequestedLabel.shouldBePresent && - (waitForArmFeedbackLabel.present || true); - - const exactlyOneArmReviewWorkflowLabelShouldBePresent = - (Number(notReadyForArmReviewLabel.shouldBePresent) + - Number(armSignedOffLabel.shouldBePresent) + - Number(armChangesRequestedLabel.shouldBePresent) + - Number(waitForArmFeedbackLabel.shouldBePresent) === - 1) || !armReviewLabelShouldBePresent - - if (!exactlyOneArmReviewWorkflowLabelShouldBePresent) { - console.warn( - "ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false" - ); - } - - notReadyForArmReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - armSignedOffLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - armChangesRequestedLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - waitForArmFeedbackLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - - console.log( - `RETURN definition processARMReviewWorkflowLabels. ` + - `presentLabels: ${[...labelContext.present].join(",")}, ` + - `blockedOnVersioningPolicy: ${blockedOnVersioningPolicy}. ` + - `blockedOnRpaas: ${blockedOnRpaas}. ` + - `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. ` - ); - return; -} - -function getBlockedOnVersioningPolicy( - labelContext: LabelContext, - breakingChangeReviewRequiredLabelShouldBePresent: boolean, - versioningReviewRequiredLabelShouldBePresent: boolean -) { - const pendingVersioningReview = - versioningReviewRequiredLabelShouldBePresent && - !anyApprovalLabelPresent("SameVersion", [...labelContext.present]); - - const pendingBreakingChangeReview = - breakingChangeReviewRequiredLabelShouldBePresent && - !anyApprovalLabelPresent("CrossVersion", [...labelContext.present]); - - const blockedOnVersioningPolicy = - pendingVersioningReview || pendingBreakingChangeReview - return blockedOnVersioningPolicy; -} - -function getBlockedOnRpaas( - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, - rpaasExceptionLabelShouldBePresent: boolean, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean -) -{ - return (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) - || ciRpaasRPNotInPrivateRepoLabelShouldBePresent -} +// /** +// This function determines which labels of the ARM review should +// be applied to given PR. It adds and removes the labels as appropriate. + +// This function does the following, **among other things**: + +// - Adds the "ARMReview" label if all of the following conditions hold: +// - The processed PR "isReleaseBranch" or "isShiftLeftPRWithRPSaaSDev" +// - The PR is not a draft, as determined by "isDraftPR" +// - The PR is labelled with "resource-manager" label, meaning it pertains +// to ARM, as previously determined by the "isManagementPR" function, +// called from the "getPRType" function. + +// - Calls the "processARMReviewWorkflowLabels" function if "ARMReview" label applies. +// */ +// // todo: refactor to take context: PRContext as input instead of IValidatorContext. +// // All downstream usage appears to be using "context.contextConfig() as PRContext". +// async function processARMReview( +// context: IValidatorContext, +// owner: string, +// repo: string, +// issue_number: number, +// labelContext: LabelContext, +// resourceManagerLabelShouldBePresent: boolean, +// versioningReviewRequiredLabelShouldBePresent: boolean, +// breakingChangeReviewRequiredLabelShouldBePresent: boolean, +// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, +// rpaasExceptionLabelShouldBePresent: boolean, +// ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean, +// isDraft: boolean, +// ): Promise<{ armReviewLabelShouldBePresent: boolean }> { +// console.log("ENTER definition processARMReview") + +// const armReviewLabel = new Label("ARMReview", labelContext.present); +// // By default this label should not be present. We may determine later in this function that it should be present after all. +// armReviewLabel.shouldBePresent = false; + +// const newApiVersionLabel = new Label("new-api-version", labelContext.present); +// // By default this label should not be present. We may determine later in this function that it should be present after all. +// newApiVersionLabel.shouldBePresent = false; + +// const branch = (context.contextConfig() as PRContext).targetBranch +// const prTitle = await getPrTitle(owner, repo, prNumber) +// const isReleaseBranchVal: boolean = isReleaseBranch(branch) +// const isShiftLeftPRWithRPSaaSDevVal: boolean = isShiftLeftPRWithRPSaaSDev(prTitle, branch) +// const isBranchInScopeOfSpecReview: boolean = isReleaseBranchVal || isShiftLeftPRWithRPSaaSDevVal +// let isNewApiVersionVal: boolean | "not_computed" = "not_computed" +// let isMissingBaseCommit: boolean | "not_computed" = "not_computed" + +// // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic +// // determines which kind of review exactly we need. +// let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview +// if (specReviewApplies) { +// isNewApiVersionVal = await isNewApiVersion(context) +// if (isNewApiVersionVal) { +// // Note that in case of data-plane PRs, the addition of this label will result +// // in API stewardship board review being required. +// // See requiredLabelsRules.ts. +// newApiVersionLabel.shouldBePresent = true; +// } + +// armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent +// await processARMReviewWorkflowLabels( +// labelContext, +// armReviewLabel.shouldBePresent, +// versioningReviewRequiredLabelShouldBePresent, +// breakingChangeReviewRequiredLabelShouldBePresent, +// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, +// rpaasExceptionLabelShouldBePresent, +// ciRpaasRPNotInPrivateRepoLabelShouldBePresent) +// } + +// newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) +// armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) + +// console.log(`RETURN definition processARMReview. ` +// + `url: ${new PRKey(owner, repo, prNumber).toUrl()}, owner: ${owner}, repo: ${repo}, pr: ${prNumber}, branch: ${branch}, ` +// + `isReleaseBranch: ${isReleaseBranchVal}, ` +// + `isShiftLeftPRWithRPSaaSDev: ${isShiftLeftPRWithRPSaaSDevVal}, ` +// + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` +// + `isNewApiVersion: ${isNewApiVersionVal}, ` +// + `isMissingBaseCommit: ${isMissingBaseCommit}, ` +// + `isDraft: ${isDraft}, ` +// + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` +// + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) + +// return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } +// } + +// function isReleaseBranch(branchName: string) { +// const branchRegex = [/main/, /RPSaaSMaster/, /release*/, /ARMCoreRPDev/]; +// return branchRegex.some((b) => b.test(branchName)); +// } + +// // For shift-left review process, if the PR target branch is RPSaaSDev, it still need ARM review +// function isShiftLeftPRWithRPSaaSDev( +// prTitle: string, +// branch: string +// ): boolean { +// const shiftLeftPRTitle = "[AutoSync]"; +// const isShiftLeftPR = prTitle.includes(shiftLeftPRTitle) && branch === "RPSaaSDev"; +// if (isShiftLeftPR) { +// console.log( +// `The PR is shift-left PR with RPSaaSDev branch. it need ARM review` +// ); +// } +// return isShiftLeftPR; +// } + +// async function isNewApiVersion(context: IValidatorContext): Promise { +// const pr = await createPullRequestProperties( +// context, +// "pr-summary-new-api-version" +// ); +// const handlers: ChangeHandler[] = []; +// let isAddingNewApiVersion = false; +// const apiVersionSet = new Set(); + +// const rpFolders = new Set(); + +// const createSwaggerFileHandler = () => { +// return (e: PRChange) => { +// if (e.changeType === "Addition") { +// const apiVersion = getApiVersionFromSwaggerFile(e.filePath); +// if (apiVersion) { +// apiVersionSet.add(apiVersion); +// } +// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); +// if (rpFolder !== undefined) { +// rpFolders.add(rpFolder); +// } +// console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); +// } else if (e.changeType === "Update") { +// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); +// if (rpFolder !== undefined) { +// rpFolders.add(rpFolder); +// } +// } +// }; +// }; + +// handlers.push({ SwaggerFile: createSwaggerFileHandler() }); +// await processPrChanges(context, handlers); + +// console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); + +// const firstRPFolder = Array.from(rpFolders)[0]; + +// console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); + +// if (firstRPFolder === undefined) { +// console.log("RP folder not found."); +// return false; +// } + +// const targetBranchRPFolder = resolve(pr?.workingDir!, firstRPFolder); + +// console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); + +// const existingApiVersions = +// getAllApiVersionFromRPFolder(targetBranchRPFolder); + +// console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); + +// for (const apiVersion of apiVersionSet) { +// if (!existingApiVersions.includes(apiVersion)) { +// console.log( +// `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` +// ); +// isAddingNewApiVersion = true; +// } +// } +// return isAddingNewApiVersion; +// } + +// /** +// CODESYNC: +// - requiredLabelsRules.ts / requiredLabelsRules +// - https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml + +// This function determines which label from the ARM review workflow labels +// should be present on the PR. It adds and removes the labels as appropriate. + +// In other words, this function captures the +// ARM review workflow label processing logic. + +// To be exact, this function executes if and only if the PR in question +// has been determined to have the "ARMReview" label, denoting given PR +// is in scope for ARM review. + +// The implementation of this function is the source of truth specifying the +// desired behavior. + +// To understand this implementation, the most important constraint to keep in mind +// is that if "ARMReview" label is present, then exactly one of the following +// labels must be present: + +// - NotReadyForARMReview +// - WaitForARMFeedback +// - ARMChangesRequested +// - ARMSignedOff + +// Note that another important place in this codebase where ARM review workflow +// labels are being removed or added to a PR is pipelineBotOnPRLabelEvent.ts. +// */ +// async function processARMReviewWorkflowLabels( +// labelContext: LabelContext, +// armReviewLabelShouldBePresent: boolean, +// versioningReviewRequiredLabelShouldBePresent: boolean, +// breakingChangeReviewRequiredLabelShouldBePresent: boolean, +// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, +// rpaasExceptionLabelShouldBePresent: boolean, +// ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean +// ): Promise { +// console.log("ENTER definition processARMReviewWorkflowLabels"); + +// const notReadyForArmReviewLabel = new Label( +// "NotReadyForARMReview", +// labelContext.present); + +// const waitForArmFeedbackLabel = new Label( +// "WaitForARMFeedback", +// labelContext.present +// ); + +// const armChangesRequestedLabel = new Label( +// "ARMChangesRequested", +// labelContext.present +// ); + +// const armSignedOffLabel = new Label("ARMSignedOff", labelContext.present); + +// const blockedOnVersioningPolicy = getBlockedOnVersioningPolicy( +// labelContext, +// breakingChangeReviewRequiredLabelShouldBePresent, +// versioningReviewRequiredLabelShouldBePresent +// ); + +// const blockedOnRpaas = getBlockedOnRpaas( +// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, +// rpaasExceptionLabelShouldBePresent, +// ciRpaasRPNotInPrivateRepoLabelShouldBePresent +// ); + +// const blocked = blockedOnVersioningPolicy || blockedOnRpaas; + +// // If given PR is in scope of ARM review and it is blocked for any reason, +// // the "NotReadyForARMReview" label should be present, to the exclusion +// // of all other ARM review workflow labels. +// notReadyForArmReviewLabel.shouldBePresent = +// armReviewLabelShouldBePresent && blocked; + +// // If given PR is in scope of ARM review and the review is not blocked, +// // then "ARMSignedOff" label should remain present on the PR if it was +// // already present. This means that labels "ARMChangesRequested" +// // and "WaitForARMFeedback" are invalid and will be removed by automation +// // in presence of "ARMSignedOff". +// armSignedOffLabel.shouldBePresent = +// armReviewLabelShouldBePresent && +// !blocked && +// armSignedOffLabel.present; + +// // If given PR is in scope of ARM review and the review is not blocked and +// // not signed-off, then the label "ARMChangesRequested" should remain present +// // if it was already present. This means that labels "WaitForARMFeedback" +// // is invalid and will be removed by automation in presence of +// // "WaitForARMFeedback". +// armChangesRequestedLabel.shouldBePresent = +// armReviewLabelShouldBePresent && +// !blocked && +// !armSignedOffLabel.shouldBePresent && +// armChangesRequestedLabel.present; + +// // If given PR is in scope of ARM review and the review is not blocked and +// // not signed-off, and ARM reviewer didn't request any changes, +// // then the label "WaitForARMFeedback" should be present on the PR, whether +// // it was present before or not. +// waitForArmFeedbackLabel.shouldBePresent = +// armReviewLabelShouldBePresent && +// !blocked && +// !armSignedOffLabel.shouldBePresent && +// !armChangesRequestedLabel.shouldBePresent && +// (waitForArmFeedbackLabel.present || true); + +// const exactlyOneArmReviewWorkflowLabelShouldBePresent = +// (Number(notReadyForArmReviewLabel.shouldBePresent) + +// Number(armSignedOffLabel.shouldBePresent) + +// Number(armChangesRequestedLabel.shouldBePresent) + +// Number(waitForArmFeedbackLabel.shouldBePresent) === +// 1) || !armReviewLabelShouldBePresent + +// if (!exactlyOneArmReviewWorkflowLabelShouldBePresent) { +// console.warn( +// "ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false" +// ); +// } + +// notReadyForArmReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); +// armSignedOffLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); +// armChangesRequestedLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); +// waitForArmFeedbackLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + +// console.log( +// `RETURN definition processARMReviewWorkflowLabels. ` + +// `presentLabels: ${[...labelContext.present].join(",")}, ` + +// `blockedOnVersioningPolicy: ${blockedOnVersioningPolicy}. ` + +// `blockedOnRpaas: ${blockedOnRpaas}. ` + +// `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. ` +// ); +// return; +// } + +// function getBlockedOnVersioningPolicy( +// labelContext: LabelContext, +// breakingChangeReviewRequiredLabelShouldBePresent: boolean, +// versioningReviewRequiredLabelShouldBePresent: boolean +// ) { +// const pendingVersioningReview = +// versioningReviewRequiredLabelShouldBePresent && +// !anyApprovalLabelPresent("SameVersion", [...labelContext.present]); + +// const pendingBreakingChangeReview = +// breakingChangeReviewRequiredLabelShouldBePresent && +// !anyApprovalLabelPresent("CrossVersion", [...labelContext.present]); + +// const blockedOnVersioningPolicy = +// pendingVersioningReview || pendingBreakingChangeReview +// return blockedOnVersioningPolicy; +// } + +// function getBlockedOnRpaas( +// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, +// rpaasExceptionLabelShouldBePresent: boolean, +// ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean +// ) +// { +// return (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) +// || ciRpaasRPNotInPrivateRepoLabelShouldBePresent +// } // #endregion // #region checks /** @@ -909,7 +1006,7 @@ function getBlockedOnRpaas( * @param {string} head_sha - The commit SHA to check. * @param {number} prNumber - The pull request number. * @param {string[]} excludedCheckNames - * @returns {Promise<[CheckRunData[], CheckRunData[]]>} + * @returns {Promise<[CheckRunData[], CheckRunData[], WorkflowRunInfo[]]>} */ export async function getCheckRunTuple( github, @@ -930,11 +1027,14 @@ export async function getCheckRunTuple( const response = await github.graphql(getGraphQLQuery(owner, repo, head_sha, prNumber)); core.info(`GraphQL Rate Limit Information: ${JSON.stringify(response.rateLimit)}`); - [reqCheckRuns, fyiCheckRuns] = extractRunsFromGraphQLResponse(response); + /** @type {WorkflowRunInfo[]} */ + let workflowRuns = []; + [reqCheckRuns, fyiCheckRuns, workflowRuns] = extractRunsFromGraphQLResponse(response); core.info( `RequiredCheckRuns: ${JSON.stringify(reqCheckRuns)}, ` + - `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}`, + `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}, ` + + `WorkflowRuns: ${JSON.stringify(workflowRuns)}`, ); const filteredReqCheckRuns = reqCheckRuns.filter( /** @@ -949,7 +1049,7 @@ export async function getCheckRunTuple( (checkRun) => !excludedCheckNames.includes(checkRun.name), ); - return [filteredReqCheckRuns, filteredFyiCheckRuns]; + return [filteredReqCheckRuns, filteredFyiCheckRuns, workflowRuns]; } /** @@ -974,21 +1074,28 @@ export function checkRunIsSuccessful(checkRun) { } /** - * @param {any} response - GraphQL response data - * @returns {[CheckRunData[], CheckRunData[]]} + * @param {GraphQLResponse} response - GraphQL response data + * @returns {[CheckRunData[], CheckRunData[], WorkflowRunInfo[]]} */ function extractRunsFromGraphQLResponse(response) { /** @type {CheckRunData[]} */ const reqCheckRuns = []; /** @type {CheckRunData[]} */ const fyiCheckRuns = []; + /** @type {WorkflowRunInfo[]} */ + const workflowRuns = []; // Define the automated merging requirements check name if (response.resource?.checkSuites?.nodes) { response.resource.checkSuites.nodes.forEach( - /** @param {{ checkRuns?: { nodes?: any[] } }} checkSuiteNode */ + /** @param {{ checkRuns?: { nodes?: any[] }, workflowRun?: WorkflowRunInfo }} checkSuiteNode */ (checkSuiteNode) => { + // Extract workflow run information if available + if (checkSuiteNode.workflowRun) { + workflowRuns.push(checkSuiteNode.workflowRun); + } + if (checkSuiteNode.checkRuns?.nodes) { checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { // We have some specific guidance for some of the required checks. @@ -1027,7 +1134,29 @@ function extractRunsFromGraphQLResponse(response) { }, ); } - return [reqCheckRuns, fyiCheckRuns]; + + // Also extract workflow runs from the repository.object.associatedPullRequests path + if (response.repository?.object?.associatedPullRequests?.nodes) { + response.repository.object.associatedPullRequests.nodes.forEach(prNode => { + if (prNode.commits?.nodes) { + prNode.commits.nodes.forEach(commitNode => { + if (commitNode.commit?.checkSuites?.nodes) { + commitNode.commit.checkSuites.nodes.forEach(checkSuiteNode => { + if (checkSuiteNode.workflowRun) { + // Avoid duplicates by checking if we already have this workflow run + const existingRun = workflowRuns.find(run => run.id === checkSuiteNode.workflowRun.id); + if (!existingRun) { + workflowRuns.push(checkSuiteNode.workflowRun); + } + } + }); + } + }); + } + }); + } + + return [reqCheckRuns, fyiCheckRuns, workflowRuns]; } // #endregion // #region next steps @@ -1246,3 +1375,81 @@ function buildViolatedLabelRulesNextStepsText(violatedRequiredLabelsRules) { return violatedReqLabelsNextStepsText; } // #endregion + +// #region artifact downloading +/** + * Downloads the job-summary artifact from the summarize-impact workflow + * @param {import('@actions/github-script').AsyncFunctionArguments['github']} github + * @param {typeof import("@actions/core")} core + * @param {string} owner + * @param {string} repo + * @param {string} head_sha + * @returns {Promise} The parsed job summary data + */ +export async function downloadJobSummaryArtifact(github, core, owner, repo, head_sha) { + try { + // Get workflow runs for the commit + const workflowRuns = await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + head_sha, + status: 'completed' + }); + + // Find the summarize-impact workflow run + const summarizeImpactRun = workflowRuns.data.workflow_runs.find(run => + run.name === '[TEST-IGNORE] Summarize PR Impact' + ); + + if (!summarizeImpactRun) { + core.info('No summarize-impact workflow run found for this commit'); + return null; + } + + // Get artifacts for the workflow run + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner, + repo, + run_id: summarizeImpactRun.id + }); + + // Find the job-summary artifact + const jobSummaryArtifact = artifacts.data.artifacts.find(artifact => + artifact.name === 'job-summary' + ); + + if (!jobSummaryArtifact) { + core.info('No job-summary artifact found'); + return null; + } + + // Download the artifact + const download = await github.rest.actions.downloadArtifact({ + owner, + repo, + artifact_id: jobSummaryArtifact.id, + archive_format: 'zip' + }); + + // The download.data is a buffer containing the zip file + // You'll need to extract and parse the JSON using JSZip + // const JSZip = require('jszip'); + // const zip = new JSZip(); + // const contents = await zip.loadAsync(download.data); + // const fileName = Object.keys(contents.files)[0]; + // const fileContent = await contents.files[fileName].async('text'); + // const jobSummary = JSON.parse(fileContent); + + core.info(`Successfully found job summary artifact with ID: ${jobSummaryArtifact.id}`); + return { + artifactId: jobSummaryArtifact.id, + downloadUrl: jobSummaryArtifact.archive_download_url, + data: download.data + }; + + } catch (/** @type {any} */ error) { + core.error(`Failed to download job summary artifact: ${error.message}`); + return null; + } +} +// #endregion diff --git a/eng/tools/summarize-impact/package.json b/eng/tools/summarize-impact/package.json index c9f749866ba5..802c34089b1f 100644 --- a/eng/tools/summarize-impact/package.json +++ b/eng/tools/summarize-impact/package.json @@ -16,6 +16,8 @@ }, "dependencies": { "@azure-tools/specs-shared": "file:../../../.github/shared", + "@azure-tools/openapi-tools-common": "^1.2.2", + "@azure/openapi-markdown": "0.9.4", "glob": "latest", "@ts-common/commonmark-to-markdown": "^2.0.2", "commonmark": "^0.29.0", diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index ff6ee773a104..c089d1f16b20 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -98,7 +98,7 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont // Has to be run in a PR context. Uses addition and update to understand // if the suppressions have been changed. If they have, suppressionReviewRequired must be added // as a label - await processSuppression(context, labelContext); + const suppressionRequired = await processSuppression(context, labelContext); // Has to run in PR context. // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index 2e81a62c5d57..6c7f8b5d5ff1 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -1,9 +1,12 @@ -// import { join } from "path"; - +import { join, dirname } from "path"; +import * as amd from "@azure/openapi-markdown"; +import { parseMarkdown } from "@azure-tools/openapi-tools-common"; +import * as fs from "fs"; // import { Readme } from "@azure-tools/specs-shared/readme"; import { SpecModel } from "@azure-tools/specs-shared/spec-model"; -import { swagger, typespec, example } from "@azure-tools/specs-shared/changed-files"; //readme, +import { swagger, typespec, example, readme, specification } from "@azure-tools/specs-shared/changed-files"; //readme, +import { Readme } from "@azure-tools/specs-shared/readme"; export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; export type ChangeTypes = "Addition" | "Deletion" | "Update"; @@ -20,6 +23,22 @@ export type ReadmeTag = { tags: DiffResult; }; +export type TagConfigDiff = { + name: string; + oldConfig?:any; + newConfig?:any + difference?:any; + changedInputFiles?:string[] +} + +export type TagDiff = { + readme: string + changes: string[] + insertions: string[] + deletions: string[] + differences?: TagConfigDiff[] +} + export type ChangeHandler = { [key in FileTypes]?: (event: PRChange) => void | Promise; }; @@ -282,6 +301,12 @@ export type PRContextOptions = { isDraft: boolean; }; +const toDiffResult = (results: string[]) => { + return { + additions: results, + } as DiffResult; +}; + export class PRContext { // we are starting with checking out before and after to different directories // sourceDirectory corresponds to "after". EG the PR context is the "after" state of the PR. @@ -319,8 +344,20 @@ export class PRContext { this.fileList = options.fileList; this.isDraft = options.isDraft; this.owner = options.owner; + } + async getChangedFiles(): Promise { + if (!this.fileList) { + return []; + } + + const changedFiles: string[] = (this.fileList.additions || []) + .concat(this.fileList.modifications || []) + .concat(this.fileList.deletions || []) + .concat(this.fileList.renames.map(r => r.to) || []); + return changedFiles; + } // todo get rid of async here not necessary // todo store the results @@ -384,37 +421,177 @@ export class PRContext { }); } - async getReadmeDiffs(): Promise> { - // todo: properly implement this - // get all the readme files - // generate the diffresult from it. - // Readme - - // console.log(`ahahahah readme tags are ${rmtags}`) - - // console.log("ENTER definition LocalDirContext.getReadmeDiffs") - // if (this.readmeDiffs) { - // console.log("RETURN definition LocalDirContext.getReadmeDiffs - early return") - // return this.readmeDiffs; - // } - // const readmeDiffs = await enumerateFiles( - // this.context.swaggerDirs[0], - // this.options?.pattern?.readme || defaultFilePatterns.readme - // ); - // const readmeTags = readmeDiffs.map((readmeDiff) => { - // return { - // readme: readmeDiff as string, - // tags: toDiffResult(getAllTags(readFileSync(readmeDiff as string).toString())), - // } as ReadmeTag; - // }); - // this.readmeDiffs = { additions: readmeTags }; - // console.log("RETURN definition LocalDirContext.getReadmeDiffs") - // return this.readmeDiffs; - return Promise.resolve({ - additions: [], - deletions: [], - changes: [] - }); + async getTagsFromReadme(readmePath: string): Promise { + const tags = await new Readme(readmePath).getTags() ; + return [...tags.values()].map(tag => tag.name); + } + + async getChangingConfigureFiles(): Promise { + console.log("ENTER definition getChangingConfigureFiles") + const changedFiles = await this.getChangedFiles(); + console.log(`Detect changes in the PR:\n${JSON.stringify(changedFiles, null, 2)}`); + const readmes = changedFiles.filter(f => readme(f)); + + const visitedFolder = new Set() + changedFiles.filter(f => [".md",".json",".yaml",".yml"].some(p => f.endsWith(p))).forEach( + f => { + let dir = dirname(f) + if (visitedFolder.has(dir)) { + return + } + while (specification(dir)) { + if (visitedFolder.has(dir)) { + break + } + visitedFolder.add(dir) + const possibleReadme = join(dir, "readme.md") + if (fs.existsSync(possibleReadme)) { + if (!readmes.includes(possibleReadme)) { + readmes.push(possibleReadme) + } + break + } + dir = dirname(dir) + } + } + ) + console.log("RETURN definition getChangingConfigureFiles") + return readmes + } + + getAllTags(readMeContent: string):string[] { + const cmd = parseMarkdown(readMeContent); + const allTags = new amd.ReadMeManipulator({ error: (_msg: string) => {}}, new amd.ReadMeBuilder()).getAllTags(cmd) + return [...allTags] } + async getInputFiles(readMeContent: string,tag:string) { + const cmd = parseMarkdown(readMeContent); + return amd.getInputFilesForTag(cmd.markDown, tag); + } + + async getChangingTags(): Promise { + // this gets all of the readme diffs. no matter if it's removal + // const allAffectedReadmes: string[] = await this.pr.getChangingConfigureFiles() + // this is retrieving _all_ files from additions, renames, modifications, and deletions. + // then we will check if the file is a readme. + const allAffectedReadmes: string[] = await this.getChangingConfigureFiles(); + console.log(`all affected readme are:`) + console.log(JSON.stringify(allAffectedReadmes,null,2)) + const Diffs: TagDiff[] = [] + for (const readme of allAffectedReadmes) { + const oldReadme = join(this.targetDirectory, readme) + const newReadme = join(this.sourceDirectory, readme) + // As the readme may be not existing , need to check if it's existing. + // we are checking target and source individually, because the readme may not exist in the target branch + const oldTags:string[] = fs.existsSync(oldReadme) ? [...(await new Readme(oldReadme).getTags()).keys()] : [] + const newTags:string[] = fs.existsSync(newReadme) ? [...(await new Readme(newReadme).getTags()).keys()] : [] + const intersect = oldTags.filter(t => newTags.includes(t)).filter(tag => tag !== "all-api-versions") + const insertions = newTags.filter(t => !oldTags.includes(t)) + const deletions = oldTags.filter(t => !newTags.includes(t)) + const differences: TagConfigDiff[] = [] + + // todo: we need to ensure we get ALL effected swaggers by their relationships, not just the swagger files that are directly changed in the PR. + // right now I'm just going to filter to the ones that are directly changed in the PR. + // this is a temporary solution, we need to ensure we get all of the swagger files + // that are affected by the changes in the readme. SpecModel will be useful for this + // we want to get all of the swagger files that are affected by the changes in the readme. + // we can do that using the specmodel + // const allAffectedInputFiles = await this.getRealAffectedSwagger(readme) + // talk to Mike and ask him how we could get all affected swagger files from a readme path. + // I want to say that readme(readme).specModel.getAffectedSwaggerFiles will work? + const allAffectedInputFiles = await (await this.getChangedFiles()).filter(f => swagger(f)); + console.log(`all affected swagger files in ${readme} are:`) + console.log(JSON.stringify(allAffectedInputFiles,null,2)) + const getChangedInputFiles = async (tag: string) => { + const readmeContent = await fs.promises.readFile(newReadme, "utf-8"); + const inputFiles = await this.getInputFiles(readmeContent, tag); + if (inputFiles) { + const changedInputFiles = (inputFiles as string[]).filter(f => + allAffectedInputFiles.some(a => a.endsWith(f)) + ); + return changedInputFiles; + } + return []; + } + const changes :string[] = [] + for (const tag of intersect) { + const tagDiff:TagConfigDiff = {name:tag} + const changedInputFiles = await getChangedInputFiles(tag) + if (changedInputFiles.length) { + console.log("found changed input files under tag:" + tag) + tagDiff.changedInputFiles = changedInputFiles + changes.push(tag) + } + } + Diffs.push({readme,insertions,deletions,changes,differences}) + } + return Diffs + } + + // this function is based upon LocalDirContext.getReadmeDiffs() and CommonPRContext.getReadmeDiffs() which are + // very different from each other (because why not). This implementation is based MOSTLY on CommonPRContext + async getReadmeDiffs(): Promise> { + // this gets all of the readme diffs + // const readmeAdditions = this.fileList?.additions.filter(file => readme(file)) || []; + // const readmeChanges = this.fileList?.modifications.filter(file => readme(file)) + // const readmeDeletions = this.fileList?.deletions.filter(file => readme(file)); + //const changedFiles: DiffFileResult | undefined = await this.localPRContext?.getChangingFiles(); + + const changedFiles = await this.fileList; + const tagDiffs = await this.getChangingTags() || []; + + const readmeTagDiffs = tagDiffs + ?.filter((tagDiff: TagDiff) => readme(tagDiff.readme)) + .map((tagDiff: TagDiff) => { + return { + readme: tagDiff.readme, + tags: { + changes: tagDiff.changes, + deletions: tagDiff.deletions, + additions: tagDiff.insertions, + }, + } as ReadmeTag; + }); + + const readmeTagDiffsInAddedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( + (readmeTag: ReadmeTag): boolean => + Boolean(changedFiles?.additions.includes(readmeTag.readme)) + ); + const readmeTagDiffsInDeletedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( + (readmeTag: ReadmeTag): boolean => + Boolean(changedFiles?.deletions.includes(readmeTag.readme)) + ); + const readmeTagDiffsInChangedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( + (readmeTag: ReadmeTag): boolean => { + + // The README file that contains the API version tags that have been diffed in given readmeTag (i.e. readmeTag.tags) + // has been modified. + const readmeModified = changedFiles?.modifications.includes(readmeTag.readme) + + // The README was not modified, added or deleted; just the specs belonging to the API version tags in the README were modified. + // In such case we assume the README is 'changed' in the sense the specs belonging to the API version tags in the README were modified. + // The README file itself wasn't modified. + // We assume here the README is present in the repository - otherwise it would show up as 'deleted' or would not + // appear as readmeTag.readme in any of the readmeTagDiffs. + const readmeUnchanged = !changedFiles?.additions.includes(readmeTag.readme) && !changedFiles?.deletions.includes(readmeTag.readme) + + return readmeModified || readmeUnchanged; + } + ); + + const result = { + additions: readmeTagDiffsInAddedReadmeFiles, + deletions: readmeTagDiffsInDeletedReadmeFiles, + changes: readmeTagDiffsInChangedReadmeFiles, + }; + + console.log(`RETURN definition CommonPRContext.getReadmeDiffs. ` + + `changedFiles: ${JSON.stringify(changedFiles)}, ` + + `tagDiffs: ${JSON.stringify(tagDiffs)}, ` + + `readmeTagDiffs: ${JSON.stringify(readmeTagDiffs)}, ` + + `this.readmeDiffs: ${JSON.stringify(result)}.`); + + return result; + } } From 66d3c140348ffaf5604359195d80bf39bc9cd48d Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 18 Jul 2025 02:09:45 +0000 Subject: [PATCH 21/67] we are properly recognizing suppression required now! --- eng/tools/summarize-impact/src/runner.ts | 1 + eng/tools/summarize-impact/src/types.ts | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index c089d1f16b20..db2f87b9f910 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -99,6 +99,7 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont // if the suppressions have been changed. If they have, suppressionReviewRequired must be added // as a label const suppressionRequired = await processSuppression(context, labelContext); + console.log(`suppressionRequired: ${suppressionRequired}`); // Has to run in PR context. // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index 6c7f8b5d5ff1..4d9292ba0e71 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -301,11 +301,11 @@ export type PRContextOptions = { isDraft: boolean; }; -const toDiffResult = (results: string[]) => { - return { - additions: results, - } as DiffResult; -}; +// const toDiffResult = (results: string[]) => { +// return { +// additions: results, +// } as DiffResult; +// }; export class PRContext { // we are starting with checking out before and after to different directories From bfb429649b4f9a14a3d649b7546245f6b79daf2a Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 18 Jul 2025 17:59:20 +0000 Subject: [PATCH 22/67] discovscovered new issue with looking for the breaking change labels. --- eng/tools/summarize-impact/src/runner.ts | 12 ++++---- eng/tools/summarize-impact/test/cli.test.ts | 32 +++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index db2f87b9f910..4d62a4f76f19 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -22,7 +22,6 @@ export declare const crossVersionBreakingChangeLabelVarName = "crossVersionBreak // todo: we need to populate this so that we can tell if it's a new APIVersion down stream export async function isNewApiVersion(context: PRContext): Promise { - const handlers: ChangeHandler[] = []; let isAddingNewApiVersion = false; const apiVersionSet = new Set(); @@ -103,10 +102,12 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont // Has to run in PR context. // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present - const { - versioningReviewRequiredLabelShouldBePresent, - breakingChangeReviewRequiredLabelShouldBePresent, - } = await processBreakingChangeLabels(context, labelContext); + // const { + // versioningReviewRequiredLabelShouldBePresent, + // breakingChangeReviewRequiredLabelShouldBePresent, + // } = await processBreakingChangeLabels(context, labelContext); + const versioningReviewRequiredLabelShouldBePresent = false; + const breakingChangeReviewRequiredLabelShouldBePresent = false; // needs to examine "after" context to understand if a readme that was changed is RPaaS or not const { rpaasLabelShouldBePresent } = await processRPaaS( @@ -252,6 +253,7 @@ export const getResourceProviderFromFilePath = ( return undefined; }; +// todo: is this right? async function processBreakingChangeLabels( prContext: PRContext, labelContext: LabelContext): Promise<{ diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index b34b13bea25b..888f6253f373 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -9,7 +9,7 @@ import { evaluateImpact } from "../src/runner.js"; // const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); describe("Check Changes", () => { - it("Should return true when the file has changes", async () => { + it("Integration test 35346", async () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); @@ -41,5 +41,33 @@ describe("Check Changes", () => { expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); expect(changedFileDetails).toBeDefined(); expect(changedFileDetails.total).toEqual(293); - }); + }, 60000000); + + + it("Integration test 35982", async () => { + const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); + const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); + + const changedFileDetails = await getChangedFilesStatuses({ cwd: sourceDirectory, baseCommitish: "origin/main" }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set() + }; + + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "2bd8350d465081401a0f4f03e633eca41f0991de", + sourceBranch: "features/users/deepika/cosmos-connectors-confluent", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35982", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false + }); + + const result = await evaluateImpact(prContext, labelContext); + + expect(result).toBeDefined(); + }, 60000000); }); From 87a9bf79e9ed540684bcb810e52d491ff77537fa Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 18 Jul 2025 21:45:55 +0000 Subject: [PATCH 23/67] update formatting --- eng/tools/summarize-impact/README.md | 15 +- eng/tools/summarize-impact/src/cli.ts | 36 +- eng/tools/summarize-impact/src/runner.ts | 414 ++++++++++---------- eng/tools/summarize-impact/src/types.ts | 368 +++++++++-------- eng/tools/summarize-impact/test/cli.test.ts | 113 +++--- 5 files changed, 484 insertions(+), 462 deletions(-) diff --git a/eng/tools/summarize-impact/README.md b/eng/tools/summarize-impact/README.md index 6c4b1bcbfba6..1ee7d526e4fe 100644 --- a/eng/tools/summarize-impact/README.md +++ b/eng/tools/summarize-impact/README.md @@ -6,15 +6,14 @@ The schema of the artifact looks like: ```json { - "pr-type": ["resource-manager", "data-plane"], - "suppressionsChanged": true, - "versioningReviewRequired": false, - "breakingChangeReviewRequired": true, - "rpaasChange": true, - "newRP": true, - "rpaasRPMissing": true + "pr-type": ["resource-manager", "data-plane"], + "suppressionsChanged": true, + "versioningReviewRequired": false, + "breakingChangeReviewRequired": true, + "rpaasChange": true, + "newRP": true, + "rpaasRPMissing": true } ``` ## Invocation - diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 2a23eb07b0bb..0cb0e69c4189 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -44,7 +44,7 @@ export async function main() { number: { type: "string", short: "n", - multiple: false + multiple: false, }, sourceBranch: { type: "string", @@ -74,12 +74,12 @@ export async function main() { labels: { type: "string", short: "l", - multiple: false + multiple: false, }, isDraft: { type: "boolean", - multiple: false - } + multiple: false, + }, }, allowPositionals: true, }; @@ -90,7 +90,7 @@ export async function main() { const sourceDirectory = opts.sourceDirectory as string; const targetDirectory = opts.targetDirectory as string; const sourceGitRoot = await getRootFolder(sourceDirectory); - const targetGitRoot = await getRootFolder(targetDirectory) + const targetGitRoot = await getRootFolder(targetDirectory); const fileList = await getChangedFilesStatuses({ cwd: sourceGitRoot }); const sha = opts.sha as string; const sourceBranch = opts.sourceBranch as string; @@ -104,23 +104,19 @@ export async function main() { const labelContext: LabelContext = { present: new Set(existingLabels), toAdd: new Set(), - toRemove: new Set() + toRemove: new Set(), }; - const prContext = new PRContext( - sourceGitRoot, - targetGitRoot, - labelContext, - { - sha, - sourceBranch, - targetBranch, - repo, - prNumber, - owner, - fileList, - isDraft - }); + const prContext = new PRContext(sourceGitRoot, targetGitRoot, labelContext, { + sha, + sourceBranch, + targetBranch, + repo, + prNumber, + owner, + fileList, + isDraft, + }); evaluateImpact(prContext, labelContext); } diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/runner.ts index 4d62a4f76f19..ee7d81f4223e 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/runner.ts @@ -1,20 +1,27 @@ #!/usr/bin/env node import * as fs from "fs"; +import { glob } from "glob"; +import * as path from "path"; + import * as commonmark from "commonmark"; import yaml from "js-yaml"; -import { glob } from "glob"; import * as _ from "lodash"; -import * as path from "path"; - import { breakingChangesCheckType } from "@azure-tools/specs-shared/breaking-change"; - -// import { Swagger } from "@azure-tools/specs-shared/swagger"; -// import { includesFolder } from "@azure-tools/specs-shared/path"; -// import { getChangedFiles } from "@azure-tools/specs-shared/changed-files"; //getChangedFiles, - -import { FileTypes, ChangeTypes, PRChange, ChangeHandler, PRType, ImpactAssessment, LabelContext, PRContext, Label, DiffResult, ReadmeTag } from "./types.js" +import { + FileTypes, + ChangeTypes, + PRChange, + ChangeHandler, + PRType, + ImpactAssessment, + LabelContext, + PRContext, + Label, + DiffResult, + ReadmeTag, +} from "./types.js"; import { Readme } from "@azure-tools/specs-shared/readme"; export declare const breakingChangeLabelVarName = "breakingChangeVar"; @@ -67,32 +74,28 @@ export async function isNewApiVersion(context: PRContext): Promise { console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); - const existingApiVersions = - getAllApiVersionFromRPFolder(targetBranchRPFolder); + const existingApiVersions = getAllApiVersionFromRPFolder(targetBranchRPFolder); console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); for (const apiVersion of apiVersionSet) { if (!existingApiVersions.includes(apiVersion)) { - console.log( - `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` - ); + console.log(`The apiVersion ${apiVersion} is added. and not found in existing ApiVersions`); isAddingNewApiVersion = true; } } return isAddingNewApiVersion; } - -export async function evaluateImpact(context: PRContext, labelContext: LabelContext): Promise { +export async function evaluateImpact( + context: PRContext, + labelContext: LabelContext, +): Promise { const typeSpecLabelShouldBePresent = await processTypeSpec(context, labelContext); // examine changed files. if changedpaths includes data-plane, add "data-plane" // same for "resource-manager". We care about whether resourcemanager will be present for a later check - const { resourceManagerLabelShouldBePresent } = await processPRType( - context, - labelContext - ); + const { resourceManagerLabelShouldBePresent } = await processPRType(context, labelContext); // Has to be run in a PR context. Uses addition and update to understand // if the suppressions have been changed. If they have, suppressionReviewRequired must be added @@ -110,39 +113,35 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont const breakingChangeReviewRequiredLabelShouldBePresent = false; // needs to examine "after" context to understand if a readme that was changed is RPaaS or not - const { rpaasLabelShouldBePresent } = await processRPaaS( - context, - labelContext - ); + const { rpaasLabelShouldBePresent } = await processRPaaS(context, labelContext); // Has to be in PR context. Uses the addition to understand if the newRPNamespace label should be present. const { newRPNamespaceLabelShouldBePresent } = await processNewRPNamespace( context, labelContext, - resourceManagerLabelShouldBePresent + resourceManagerLabelShouldBePresent, ); // doesn't necessarily need to be in the PR context. // Uses the previous outputs that DID need to be a PR context, but otherwise only examines targetBranch and those // output labels. - const { - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, - rpaasExceptionLabelShouldBePresent - } = await processNewRpNamespaceWithoutRpaasLabel( - context, - labelContext, - resourceManagerLabelShouldBePresent, - newRPNamespaceLabelShouldBePresent, - rpaasLabelShouldBePresent - ); + const { ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, rpaasExceptionLabelShouldBePresent } = + await processNewRpNamespaceWithoutRpaasLabel( + context, + labelContext, + resourceManagerLabelShouldBePresent, + newRPNamespaceLabelShouldBePresent, + rpaasLabelShouldBePresent, + ); // examines the additions. if the changetype is addition, then it will add the ciRpaasRPNotInPrivateRepo label - const { ciRpaasRPNotInPrivateRepoLabelShouldBePresent } = await processRpaasRpNotInPrivateRepoLabel( - context, - labelContext, - resourceManagerLabelShouldBePresent, - rpaasLabelShouldBePresent - ); + const { ciRpaasRPNotInPrivateRepoLabelShouldBePresent } = + await processRpaasRpNotInPrivateRepoLabel( + context, + labelContext, + resourceManagerLabelShouldBePresent, + rpaasLabelShouldBePresent, + ); const newApiVersion = await isNewApiVersion(context); @@ -160,7 +159,7 @@ export async function evaluateImpact(context: PRContext, labelContext: LabelCont typeSpecChanged: typeSpecLabelShouldBePresent, isNewApiVersion: newApiVersion, isDraft: context.isDraft, - labelContext: labelContext + labelContext: labelContext, }; } @@ -180,9 +179,7 @@ export function getAllApiVersionFromRPFolder(rpFolder: string): string[] { for (const it of allSwaggerFilesFromRPFolder) { if (!it.includes("examples")) { const apiVersion = getApiVersionFromSwaggerFile(it); - console.log( - `Get api version from swagger file: ${it}, apiVersion: ${apiVersion}` - ); + console.log(`Get api version from swagger file: ${it}, apiVersion: ${apiVersion}`); if (apiVersion) { apiVersions.add(apiVersion); } @@ -191,9 +188,7 @@ export function getAllApiVersionFromRPFolder(rpFolder: string): string[] { return [...apiVersions]; } -export function getApiVersionFromSwaggerFile( - swaggerFile: string -): string | undefined { +export function getApiVersionFromSwaggerFile(swaggerFile: string): string | undefined { const swagger = fs.readFileSync(swaggerFile).toString(); const swaggerObject = JSON.parse(swagger); if (swaggerObject["info"] && swaggerObject["info"]["version"]) { @@ -202,9 +197,7 @@ export function getApiVersionFromSwaggerFile( return undefined; } -export function getRPFolderFromSwaggerFile( - swaggerFile: string -): string | undefined { +export function getRPFolderFromSwaggerFile(swaggerFile: string): string | undefined { const resourceProvider = getResourceProviderFromFilePath(swaggerFile); if (resourceProvider === undefined) { @@ -215,10 +208,7 @@ export function getRPFolderFromSwaggerFile( return swaggerFile.substring(0, lastIdx + resourceProvider!.length); } -export const getResourceProviderFromFilePath = ( - filePath: string -): string | undefined => { - +export const getResourceProviderFromFilePath = (filePath: string): string | undefined => { // Example filePath: // specification/purview/data-plane/Azure.Analytics.Purview.Workflow/preview/2023-10-01-preview/purviewWorkflow.json // Match: @@ -256,26 +246,30 @@ export const getResourceProviderFromFilePath = ( // todo: is this right? async function processBreakingChangeLabels( prContext: PRContext, - labelContext: LabelContext): Promise<{ - versioningReviewRequiredLabelShouldBePresent: boolean, - breakingChangeReviewRequiredLabelShouldBePresent: boolean, - }>{ - + labelContext: LabelContext, +): Promise<{ + versioningReviewRequiredLabelShouldBePresent: boolean; + breakingChangeReviewRequiredLabelShouldBePresent: boolean; +}> { const prTargetsProductionBranch: boolean = checkPrTargetsProductionBranch(prContext); const breakingChangesLabelsFromOad = getBreakingChangesLabelsFromOad(); const versioningReviewRequiredLabel = new Label( breakingChangesCheckType.SameVersion.reviewRequiredLabel, - labelContext.present + labelContext.present, ); const breakingChangeReviewRequiredLabel = new Label( breakingChangesCheckType.CrossVersion.reviewRequiredLabel, - labelContext.present + labelContext.present, ); - const versioningApprovalLabels = breakingChangesCheckType.SameVersion.approvalLabels.map((label: string) => new Label(label, labelContext.present)); - const breakingChangeApprovalLabels = breakingChangesCheckType.CrossVersion.approvalLabels.map((label: string) => new Label(label, labelContext.present)); + const versioningApprovalLabels = breakingChangesCheckType.SameVersion.approvalLabels.map( + (label: string) => new Label(label, labelContext.present), + ); + const breakingChangeApprovalLabels = breakingChangesCheckType.CrossVersion.approvalLabels.map( + (label: string) => new Label(label, labelContext.present), + ); // ----- Breaking changes label processing logic ----- // These lines implement the set of rules determining which of the breaking change @@ -288,28 +282,30 @@ async function processBreakingChangeLabels( breakingChangeReviewRequiredLabel.shouldBePresent = // "BreakingChangeReviewRequired" label should be present if // 1. The PR targets a production branch - prTargetsProductionBranch + prTargetsProductionBranch && // 2. AND given OAD run determined it is still applicable. - && breakingChangesLabelsFromOad.includes(breakingChangeReviewRequiredLabel.name) + breakingChangesLabelsFromOad.includes(breakingChangeReviewRequiredLabel.name); // ❗ IMPORTANT ❗: this MUST be set AFTER breakingChangeReviewRequiredLabel.shouldBePresent // due to logical dependency versioningReviewRequiredLabel.shouldBePresent = // "VersioningReviewRequired" label should be unconditionally removed if // the label "BreakingChangeReviewRequired" should be present. - !breakingChangeReviewRequiredLabel.shouldBePresent + !breakingChangeReviewRequiredLabel.shouldBePresent && // AND "VersioningReviewRequired" label should be present if // 1. The PR targets a production branch - && prTargetsProductionBranch + prTargetsProductionBranch && // 2. AND given OAD run determined it is still applicable. - && breakingChangesLabelsFromOad.includes(versioningReviewRequiredLabel.name); + breakingChangesLabelsFromOad.includes(versioningReviewRequiredLabel.name); - versioningApprovalLabels.forEach(approvalLabel => { - approvalLabel.shouldBePresent = approvalLabel.present && versioningReviewRequiredLabel.shouldBePresent; + versioningApprovalLabels.forEach((approvalLabel) => { + approvalLabel.shouldBePresent = + approvalLabel.present && versioningReviewRequiredLabel.shouldBePresent; }); - breakingChangeApprovalLabels.forEach(approvalLabel => { - approvalLabel.shouldBePresent = approvalLabel.present && breakingChangeReviewRequiredLabel.shouldBePresent; + breakingChangeApprovalLabels.forEach((approvalLabel) => { + approvalLabel.shouldBePresent = + approvalLabel.present && breakingChangeReviewRequiredLabel.shouldBePresent; }); // ---------- @@ -320,7 +316,8 @@ async function processBreakingChangeLabels( return { versioningReviewRequiredLabelShouldBePresent: versioningReviewRequiredLabel.shouldBePresent, - breakingChangeReviewRequiredLabelShouldBePresent: breakingChangeReviewRequiredLabel.shouldBePresent + breakingChangeReviewRequiredLabelShouldBePresent: + breakingChangeReviewRequiredLabel.shouldBePresent, }; /** @@ -339,7 +336,10 @@ async function processBreakingChangeLabels( .map((labelSenderEnvVar) => process.env[labelSenderEnvVar]) .filter((envVarValue) => envVarValue !== undefined) .map((envVarValue) => envVarValue?.split(",") || []) - .reduce((accumulatedLabels, currentEnvVarLabels) => accumulatedLabels.concat(currentEnvVarLabels), []) + .reduce( + (accumulatedLabels, currentEnvVarLabels) => accumulatedLabels.concat(currentEnvVarLabels), + [], + ), ).filter((label) => label); return breakingChangeLabelsFromOad; } @@ -347,32 +347,35 @@ async function processBreakingChangeLabels( function applyLabelsStateChanges(labelsToAdd: Set, labelsToRemove: Set) { breakingChangeReviewRequiredLabel.applyStateChange(labelsToAdd, labelsToRemove); versioningReviewRequiredLabel.applyStateChange(labelsToAdd, labelsToRemove); - versioningApprovalLabels.forEach(approvalLabel => { + versioningApprovalLabels.forEach((approvalLabel) => { approvalLabel.applyStateChange(labelsToAdd, labelsToRemove); }); - breakingChangeApprovalLabels.forEach(approvalLabel => { + breakingChangeApprovalLabels.forEach((approvalLabel) => { approvalLabel.applyStateChange(labelsToAdd, labelsToRemove); }); } function logDiagInfo() { - if (!prTargetsProductionBranch - && ( - breakingChangesLabelsFromOad.includes(breakingChangeReviewRequiredLabel.name) - || breakingChangesLabelsFromOad.includes(versioningReviewRequiredLabel.name) - )) { - // We are using this log as a metric to track and measure impact of the work on improving "breaking changes" tooling. Log statement added around 11/29/2023. - // See: https://github.com/Azure/azure-sdk-tools/issues/7223#issuecomment-1839830834 - // Note it duplicates the label "shouldBePresent" ruleset logic. - console.log(`processBreakingChangeLabels: PR: ${`https://github.com/${prContext.owner}/${prContext.repo}/pull/${prContext.prNumber}`}, targetBranch: ${prContext.targetBranch}. ` - + `The addition of 'BreakingChangesReviewRequired' or 'VersioningReviewRequired' labels has been prevented ` - + `because we checked that the PR is not targeting a production branch.`); + if ( + !prTargetsProductionBranch && + (breakingChangesLabelsFromOad.includes(breakingChangeReviewRequiredLabel.name) || + breakingChangesLabelsFromOad.includes(versioningReviewRequiredLabel.name)) + ) { + // We are using this log as a metric to track and measure impact of the work on improving "breaking changes" tooling. Log statement added around 11/29/2023. + // See: https://github.com/Azure/azure-sdk-tools/issues/7223#issuecomment-1839830834 + // Note it duplicates the label "shouldBePresent" ruleset logic. + console.log( + `processBreakingChangeLabels: PR: ${`https://github.com/${prContext.owner}/${prContext.repo}/pull/${prContext.prNumber}`}, targetBranch: ${prContext.targetBranch}. ` + + `The addition of 'BreakingChangesReviewRequired' or 'VersioningReviewRequired' labels has been prevented ` + + `because we checked that the PR is not targeting a production branch.`, + ); } - console.log(`processBreakingChangeLabels returned. ` - + `prTargetsProductionBranch: ${prTargetsProductionBranch}, ` - + breakingChangeReviewRequiredLabel.logString() - + versioningReviewRequiredLabel.logString() + console.log( + `processBreakingChangeLabels returned. ` + + `prTargetsProductionBranch: ${prTargetsProductionBranch}, ` + + breakingChangeReviewRequiredLabel.logString() + + versioningReviewRequiredLabel.logString(), ); } } @@ -381,25 +384,19 @@ async function processBreakingChangeLabels( * The "production" branches are defined at https://aka.ms/azsdk/pr-brch-deep */ function checkPrTargetsProductionBranch(prContext: PRContext): boolean { - - const targetsPublicProductionBranch = - prContext.repo.includes("azure-rest-api-specs") - && prContext.repo !== "azure-rest-api-specs-pr" - && prContext.targetBranch === "main"; + prContext.repo.includes("azure-rest-api-specs") && + prContext.repo !== "azure-rest-api-specs-pr" && + prContext.targetBranch === "main"; const targetsPrivateProductionBranch = - prContext.repo === "azure-rest-api-specs-pr" - && prContext.targetBranch === "RPSaaSMaster" + prContext.repo === "azure-rest-api-specs-pr" && prContext.targetBranch === "RPSaaSMaster"; - return targetsPublicProductionBranch || targetsPrivateProductionBranch + return targetsPublicProductionBranch || targetsPrivateProductionBranch; } -async function processTypeSpec( - ctx: PRContext, - labelContext: LabelContext, -) : Promise{ - console.log("ENTER definition processTypeSpec") +async function processTypeSpec(ctx: PRContext, labelContext: LabelContext): Promise { + console.log("ENTER definition processTypeSpec"); const typeSpecLabel = new Label("TypeSpec", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. typeSpecLabel.shouldBePresent = false; @@ -424,25 +421,21 @@ async function processTypeSpec( }); await processPrChanges(ctx, handlers); typeSpecLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processTypeSpec") + console.log("RETURN definition processTypeSpec"); return typeSpecLabel.shouldBePresent; } function isSwaggerGeneratedByTypeSpec(swaggerFilePath: string): boolean { try { - return !!JSON.parse(fs.readFileSync(swaggerFilePath).toString())?.info['x-typespec-generated'] - } - catch { - return false + return !!JSON.parse(fs.readFileSync(swaggerFilePath).toString())?.info["x-typespec-generated"]; + } catch { + return false; } } -export async function processPrChanges( - ctx: PRContext, - Handlers: ChangeHandler[] -) { - console.log("ENTER definition processPrChanges") +export async function processPrChanges(ctx: PRContext, Handlers: ChangeHandler[]) { + console.log("ENTER definition processPrChanges"); const prChanges = await getPRChanges(ctx); prChanges.forEach((prChange) => { Handlers.forEach((handler) => { @@ -451,34 +444,33 @@ export async function processPrChanges( } }); }); - console.log("RETURN definition processPrChanges") + console.log("RETURN definition processPrChanges"); } -export async function processPRChangesAsync( - ctx: PRContext, - Handlers: ChangeHandler[] -) { - console.log("ENTER definition processPRChangesAsync") +export async function processPRChangesAsync(ctx: PRContext, Handlers: ChangeHandler[]) { + console.log("ENTER definition processPRChangesAsync"); const prChanges = await getPRChanges(ctx); - prChanges.forEach((prChange) => { - Handlers.forEach(async (handler) => { + + for (const prChange of prChanges) { + for (const handler of Handlers) { if (prChange.fileType in handler) { await handler?.[prChange.fileType]?.(prChange); } - }); - }); - console.log("RETURN definition processPRChangesAsync") + } + } + + console.log("RETURN definition processPRChangesAsync"); } export async function getPRChanges(ctx: PRContext): Promise { - console.log("ENTER definition getPRChanges") + console.log("ENTER definition getPRChanges"); const results: PRChange[] = []; function newChange( fileType: FileTypes, changeType: ChangeTypes, filePath?: string, - additionalInfo?: any + additionalInfo?: any, ) { if (filePath) { results.push({ @@ -490,11 +482,7 @@ export async function getPRChanges(ctx: PRContext): Promise { } } - function newChanges( - fileType: FileTypes, - changeType: ChangeTypes, - files?: string[] - ) { + function newChanges(fileType: FileTypes, changeType: ChangeTypes, files?: string[]) { if (files) { files.forEach((filePath) => newChange(fileType, changeType, filePath)); } @@ -508,15 +496,9 @@ export async function getPRChanges(ctx: PRContext): Promise { function genReadmeChanges(readmeDiffs?: DiffResult) { if (readmeDiffs) { - readmeDiffs.additions?.forEach((d) => - newChange("ReadmeFile", "Addition", d.readme, d.tags) - ); - readmeDiffs.changes?.forEach((d) => - newChange("ReadmeFile", "Update", d.readme, d.tags) - ); - readmeDiffs.deletions?.forEach((d) => - newChange("ReadmeFile", "Deletion", d.readme, d.tags) - ); + readmeDiffs.additions?.forEach((d) => newChange("ReadmeFile", "Addition", d.readme, d.tags)); + readmeDiffs.changes?.forEach((d) => newChange("ReadmeFile", "Update", d.readme, d.tags)); + readmeDiffs.deletions?.forEach((d) => newChange("ReadmeFile", "Deletion", d.readme, d.tags)); } } @@ -525,27 +507,33 @@ export async function getPRChanges(ctx: PRContext): Promise { genChanges("ExampleFile", await ctx.getExampleDiffs()); genReadmeChanges(await ctx.getReadmeDiffs()); - console.log("RETURN definition getPRChanges") + console.log("RETURN definition getPRChanges"); return results; } // related pending work: // If a PR has both data-plane and resource-manager labels, it should fail with appropriate messaging #8144 // https://github.com/Azure/azure-sdk-tools/issues/8144 -async function processPRType(context: PRContext, - labelContext: LabelContext): Promise<{ resourceManagerLabelShouldBePresent: boolean }> { - console.log("ENTER definition processPRType") +async function processPRType( + context: PRContext, + labelContext: LabelContext, +): Promise<{ resourceManagerLabelShouldBePresent: boolean }> { + console.log("ENTER definition processPRType"); const types: PRType[] = await getPRType(context); - const resourceManagerLabelShouldBePresent = processPRTypeLabel("resource-manager", types, labelContext); + const resourceManagerLabelShouldBePresent = processPRTypeLabel( + "resource-manager", + types, + labelContext, + ); processPRTypeLabel("data-plane", types, labelContext); - console.log("RETURN definition processPRType") - return { resourceManagerLabelShouldBePresent } + console.log("RETURN definition processPRType"); + return { resourceManagerLabelShouldBePresent }; } async function getPRType(context: PRContext): Promise { - console.log("ENTER definition getPRType") + console.log("ENTER definition getPRType"); const prChanges: PRChange[] = await getPRChanges(context); logPRChanges(prChanges); @@ -561,21 +549,19 @@ async function getPRType(context: PRContext): Promise { prTypes.push("resource-manager"); } } - console.log("RETURN definition getPRType") + console.log("RETURN definition getPRType"); return prTypes; function logPRChanges(prChanges: PRChange[]) { console.log(`PR changes table (count: ${prChanges.length}):`); console.log("LEGEND: changeType | fileType | filePath"); prChanges - .map(it => `${it.changeType} | ${it.fileType} | ${it.filePath}`) - .forEach(it => console.log(it)); + .map((it) => `${it.changeType} | ${it.fileType} | ${it.filePath}`) + .forEach((it) => console.log(it)); console.log("END of PR changes table"); console.log("PR changes with additionalInfo:"); - prChanges - .filter(it => it.additionalInfo != null) - .forEach(it => console.log(it)); + prChanges.filter((it) => it.additionalInfo != null).forEach((it) => console.log(it)); console.log("END of PR changes with additionalInfo"); } } @@ -583,22 +569,22 @@ async function getPRType(context: PRContext): Promise { function processPRTypeLabel( labelName: PRType, types: PRType[], - labelContext: LabelContext + labelContext: LabelContext, ): boolean { const label = new Label(labelName, labelContext.present); label.shouldBePresent = types.includes(labelName); label.applyStateChange(labelContext.toAdd, labelContext.toRemove); - return label.shouldBePresent + return label.shouldBePresent; } -async function processSuppression( - context: PRContext, - labelContext: LabelContext, -) { - console.log("ENTER definition processSuppression") +async function processSuppression(context: PRContext, labelContext: LabelContext) { + console.log("ENTER definition processSuppression"); - const suppressionReviewRequiredLabel = new Label("SuppressionReviewRequired", labelContext.present); + const suppressionReviewRequiredLabel = new Label( + "SuppressionReviewRequired", + labelContext.present, + ); // By default this label should not be present. We may determine later in this function that it should be present after all. suppressionReviewRequiredLabel.shouldBePresent = false; const handlers: ChangeHandler[] = []; @@ -608,8 +594,10 @@ async function processSuppression( if ( (e.changeType === "Addition" && getSuppressions(e.filePath).length) || (e.changeType === "Update" && - diffSuppression(path.resolve(context.targetDirectory, e.filePath), path.resolve(context.sourceDirectory,e.filePath)) - .length) + diffSuppression( + path.resolve(context.targetDirectory, e.filePath), + path.resolve(context.sourceDirectory, e.filePath), + ).length) ) { suppressionReviewRequiredLabel.shouldBePresent = true; } @@ -621,13 +609,13 @@ async function processSuppression( await processPrChanges(context, handlers); suppressionReviewRequiredLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processSuppression") + console.log("RETURN definition processSuppression"); } function getSuppressions(readmePath: string) { const walkToNode = ( walker: commonmark.NodeWalker, - cb: (node: commonmark.Node) => boolean + cb: (node: commonmark.Node) => boolean, ): commonmark.Node | undefined => { let event = walker.next(); @@ -662,9 +650,7 @@ function getSuppressions(readmePath: string) { const blockObject = yaml.load(block.literal) as any; const directives = blockObject?.["directive"]; if (directives && Array.isArray(directives)) { - suppressionResult = suppressionResult.concat( - directives.filter((s) => s.suppress) - ); + suppressionResult = suppressionResult.concat(directives.filter((s) => s.suppress)); } const suppressions = blockObject?.["suppressions"]; if (suppressions && Array.isArray(suppressions)) { @@ -685,9 +671,7 @@ export function diffSuppression(readmeBefore: string, readmeAfter: string) { const properties = ["suppress", "from", "where", "code", "reason"]; if ( -1 === - beforeSuppressions.findIndex((s) => - properties.every((p) => _.isEqual(s[p], suppression[p])) - ) + beforeSuppressions.findIndex((s) => properties.every((p) => _.isEqual(s[p], suppression[p]))) ) { newSuppressions.push(suppression); } @@ -699,7 +683,7 @@ async function processRPaaS( context: PRContext, labelContext: LabelContext, ): Promise<{ rpaasLabelShouldBePresent: boolean }> { - console.log("ENTER definition processRPaaS") + console.log("ENTER definition processRPaaS"); const rpaasLabel = new Label("RPaaS", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. rpaasLabel.shouldBePresent = false; @@ -707,7 +691,10 @@ async function processRPaaS( const handlers: ChangeHandler[] = []; const createReadmeFileHandler = () => { return async (e: PRChange) => { - if (e.changeType !== "Deletion" && await isRPSaaS(e.filePath)) { + if ( + e.changeType !== "Deletion" && + (await isRPSaaS(path.join(context.sourceDirectory, e.filePath))) + ) { rpaasLabel.shouldBePresent = true; } }; @@ -718,22 +705,21 @@ async function processRPaaS( await processPRChangesAsync(context, handlers); rpaasLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processRPaaS") - return { rpaasLabelShouldBePresent: rpaasLabel.shouldBePresent } + console.log("RETURN definition processRPaaS"); + return { rpaasLabelShouldBePresent: rpaasLabel.shouldBePresent }; } async function isRPSaaS(readmeFilePath: string) { const config: any = await new Readme(readmeFilePath).getGlobalConfig(); - return config["openapi-subtype"] === "rpaas" || config["openapi-subtype"] === "providerHub"; } async function processNewRPNamespace( context: PRContext, labelContext: LabelContext, - resourceManagerLabelShouldBePresent: boolean -): Promise<{newRPNamespaceLabelShouldBePresent: boolean}> { - console.log("ENTER definition processNewRPNamespace") + resourceManagerLabelShouldBePresent: boolean, +): Promise<{ newRPNamespaceLabelShouldBePresent: boolean }> { + console.log("ENTER definition processNewRPNamespace"); const newRPNamespaceLabel = new Label("new-rp-namespace", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. newRPNamespaceLabel.shouldBePresent = false; @@ -774,8 +760,8 @@ async function processNewRPNamespace( } newRPNamespaceLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processNewRPNamespace") - return { newRPNamespaceLabelShouldBePresent: newRPNamespaceLabel.shouldBePresent } + console.log("RETURN definition processNewRPNamespace"); + return { newRPNamespaceLabelShouldBePresent: newRPNamespaceLabel.shouldBePresent }; } // CODESYNC: @@ -786,10 +772,16 @@ async function processNewRpNamespaceWithoutRpaasLabel( labelContext: LabelContext, resourceManagerLabelShouldBePresent: boolean, newRPNamespaceLabelShouldBePresent: boolean, - rpaasLabelShouldBePresent: boolean -): Promise<{ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, rpaasExceptionLabelShouldBePresent: boolean}> { - console.log("ENTER definition processNewRpNamespaceWithoutRpaasLabel") - const ciNewRPNamespaceWithoutRpaaSLabel = new Label("CI-NewRPNamespaceWithoutRPaaS", labelContext.present); + rpaasLabelShouldBePresent: boolean, +): Promise<{ + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean; + rpaasExceptionLabelShouldBePresent: boolean; +}> { + console.log("ENTER definition processNewRpNamespaceWithoutRpaasLabel"); + const ciNewRPNamespaceWithoutRpaaSLabel = new Label( + "CI-NewRPNamespaceWithoutRPaaS", + labelContext.present, + ); // By default this label should not be present. We may determine later in this function that it should be present after all. ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent = false; @@ -805,31 +797,31 @@ async function processNewRpNamespaceWithoutRpaasLabel( if (!skip) { const branch = context.targetBranch; if (branch === "RPSaaSDev" || branch === "RPSaaSMaster") { - console.log(`PR is targeting '${branch}' branch. Skip checking for 'CI-NewRPNamespaceWithoutRPaaS'`); + console.log( + `PR is targeting '${branch}' branch. Skip checking for 'CI-NewRPNamespaceWithoutRPaaS'`, + ); skip = true; } -} + } - if ( - !skip && - newRPNamespaceLabelShouldBePresent && - !rpaasLabelShouldBePresent - ) { - console.log("Adding new RP namespace, but not RPaaS. Label 'CI-NewRPNamespaceWithoutRPaaS' should be present."); + if (!skip && newRPNamespaceLabelShouldBePresent && !rpaasLabelShouldBePresent) { + console.log( + "Adding new RP namespace, but not RPaaS. Label 'CI-NewRPNamespaceWithoutRPaaS' should be present.", + ); ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent = true; } - rpaasExceptionLabel.shouldBePresent = rpaasExceptionLabel.present && ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent; + rpaasExceptionLabel.shouldBePresent = + rpaasExceptionLabel.present && ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent; ciNewRPNamespaceWithoutRpaaSLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); rpaasExceptionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processNewRpNamespaceWithoutRpaasLabel") + console.log("RETURN definition processNewRpNamespaceWithoutRpaasLabel"); return { ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: ciNewRPNamespaceWithoutRpaaSLabel.shouldBePresent, - rpaasExceptionLabelShouldBePresent: - rpaasExceptionLabel.shouldBePresent as boolean + rpaasExceptionLabelShouldBePresent: rpaasExceptionLabel.shouldBePresent as boolean, }; } @@ -838,10 +830,13 @@ async function processRpaasRpNotInPrivateRepoLabel( context: PRContext, labelContext: LabelContext, resourceManagerLabelShouldBePresent: boolean, - rpaasLabelShouldBePresent: boolean -): Promise<{ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean}> { - console.log("ENTER definition processRpaasRpNotInPrivateRepoLabel") - const ciRpaasRPNotInPrivateRepoLabel = new Label("CI-RpaaSRPNotInPrivateRepo", labelContext.present); + rpaasLabelShouldBePresent: boolean, +): Promise<{ ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean }> { + console.log("ENTER definition processRpaasRpNotInPrivateRepoLabel"); + const ciRpaasRPNotInPrivateRepoLabel = new Label( + "CI-RpaaSRPNotInPrivateRepo", + labelContext.present, + ); // By default this label should not be present. We may determine later in this function that it should be present after all. ciRpaasRPNotInPrivateRepoLabel.shouldBePresent = false; @@ -855,9 +850,7 @@ async function processRpaasRpNotInPrivateRepoLabel( if (!skip) { const targetBranch = context.targetBranch; if (targetBranch !== "main" || !rpaasLabelShouldBePresent) { - console.log( - "Not main branch or not RPaaS PR, skip block PR when RPaaS RP not onboard" - ); + console.log("Not main branch or not RPaaS PR, skip block PR when RPaaS RP not onboard"); skip = true; } } @@ -902,8 +895,9 @@ async function processRpaasRpNotInPrivateRepoLabel( // } ciRpaasRPNotInPrivateRepoLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log("RETURN definition processRpaasRpNotInPrivateRepoLabel") + console.log("RETURN definition processRpaasRpNotInPrivateRepoLabel"); - return { ciRpaasRPNotInPrivateRepoLabelShouldBePresent: ciRpaasRPNotInPrivateRepoLabel.shouldBePresent } + return { + ciRpaasRPNotInPrivateRepoLabelShouldBePresent: ciRpaasRPNotInPrivateRepoLabel.shouldBePresent, + }; } - diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index 4d9292ba0e71..ad8e9fe74c0b 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -5,7 +5,13 @@ import * as fs from "fs"; // import { Readme } from "@azure-tools/specs-shared/readme"; import { SpecModel } from "@azure-tools/specs-shared/spec-model"; -import { swagger, typespec, example, readme, specification } from "@azure-tools/specs-shared/changed-files"; //readme, +import { + swagger, + typespec, + example, + readme, + specification, +} from "@azure-tools/specs-shared/changed-files"; //readme, import { Readme } from "@azure-tools/specs-shared/readme"; export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; @@ -24,20 +30,20 @@ export type ReadmeTag = { }; export type TagConfigDiff = { - name: string; - oldConfig?:any; - newConfig?:any - difference?:any; - changedInputFiles?:string[] -} + name: string; + oldConfig?: any; + newConfig?: any; + difference?: any; + changedInputFiles?: string[]; +}; export type TagDiff = { - readme: string - changes: string[] - insertions: string[] - deletions: string[] - differences?: TagConfigDiff[] -} + readme: string; + changes: string[]; + insertions: string[]; + deletions: string[]; + differences?: TagConfigDiff[]; +}; export type ChangeHandler = { [key in FileTypes]?: (event: PRChange) => void | Promise; @@ -49,10 +55,10 @@ export type ChangeHandler = { // }; export type DiffResult = { - additions?: T[] - deletions?: T[] - changes?: T[] -} + additions?: T[]; + deletions?: T[]; + changes?: T[]; +}; // do I need to add minimatch as a dependency? What does it do? // export const isPathMatch = ( @@ -176,27 +182,27 @@ export type PRType = "resource-manager" | "data-plane"; * todo: this type is duplicated in JSDoc over in summarize-checks.js */ export type LabelContext = { - present: Set, - toAdd: Set, - toRemove: Set -} + present: Set; + toAdd: Set; + toRemove: Set; +}; export type ImpactAssessment = { - prType: string[], - resourceManagerRequired: boolean, - suppressionReviewRequired: boolean, - versioningReviewRequired: boolean, - breakingChangeReviewRequired: boolean, - isNewApiVersion: boolean, - rpaasExceptionRequired: boolean, - rpaasRpNotInPrivateRepo: boolean, - rpaasChange: boolean, - newRP: boolean, - rpaasRPMissing: boolean, - typeSpecChanged: boolean, - isDraft: boolean, - labelContext: LabelContext -} + prType: string[]; + resourceManagerRequired: boolean; + suppressionReviewRequired: boolean; + versioningReviewRequired: boolean; + breakingChangeReviewRequired: boolean; + isNewApiVersion: boolean; + rpaasExceptionRequired: boolean; + rpaasRpNotInPrivateRepo: boolean; + rpaasChange: boolean; + newRP: boolean; + rpaasRPMissing: boolean; + typeSpecChanged: boolean; + isDraft: boolean; + labelContext: LabelContext; +}; export class Label { name: string; @@ -229,48 +235,53 @@ export class Label { * * Precondition: this.shouldBePresent has been defined. */ - applyStateChange( - labelsToAdd: Set, - labelsToRemove: Set - ): void { + applyStateChange(labelsToAdd: Set, labelsToRemove: Set): void { if (this.shouldBePresent === undefined) { console.warn( "ASSERTION VIOLATION! " + `Cannot applyStateChange for label '${this.name}' ` + - "as its desired presence hasn't been defined. Returning early." + "as its desired presence hasn't been defined. Returning early.", ); return; } if (!this.present && this.shouldBePresent) { if (!labelsToAdd.has(this.name)) { - console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`); + console.log( + `Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`, + ); labelsToAdd.add(this.name); } else { - console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`); + console.log( + `Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`, + ); } } else if (this.present && !this.shouldBePresent) { if (!labelsToRemove.has(this.name)) { - console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`); + console.log( + `Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`, + ); labelsToRemove.add(this.name); } else { - console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`); + console.log( + `Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`, + ); } } else if (this.present === this.shouldBePresent) { - console.log(`Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`); + console.log( + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`, + ); } else { console.warn( "ASSERTION VIOLATION! " + - `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` - + `At this point of execution this should not happen.` + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` + + `At this point of execution this should not happen.`, ); } } isEqualToOrPrefixOf(label: string): boolean { - return this.name.endsWith("*") - ? label.startsWith(this.name.slice(0, -1)) - : this.name === label; + return this.name.endsWith("*") ? label.startsWith(this.name.slice(0, -1)) : this.name === label; } logString(): string { @@ -329,7 +340,7 @@ export class PRContext { sourceDirectory: string, targetDirectory: string, labelContext: LabelContext, - options: PRContextOptions + options: PRContextOptions, ) { this.sourceDirectory = sourceDirectory; this.targetDirectory = targetDirectory; @@ -344,7 +355,6 @@ export class PRContext { this.fileList = options.fileList; this.isDraft = options.isDraft; this.owner = options.owner; - } async getChangedFiles(): Promise { @@ -355,7 +365,7 @@ export class PRContext { const changedFiles: string[] = (this.fileList.additions || []) .concat(this.fileList.modifications || []) .concat(this.fileList.deletions || []) - .concat(this.fileList.renames.map(r => r.to) || []); + .concat(this.fileList.renames.map((r) => r.to) || []); return changedFiles; } @@ -366,18 +376,18 @@ export class PRContext { return Promise.resolve({ additions: [], deletions: [], - changes: [] + changes: [], }); } - const additions = this.fileList.additions.filter(file => typespec(file)); - const deletions = this.fileList.deletions.filter(file => typespec(file)); - const changes = this.fileList.modifications.filter(file => typespec(file)); + const additions = this.fileList.additions.filter((file) => typespec(file)); + const deletions = this.fileList.deletions.filter((file) => typespec(file)); + const changes = this.fileList.modifications.filter((file) => typespec(file)); return Promise.resolve({ additions, deletions, - changes + changes, }); } @@ -386,18 +396,18 @@ export class PRContext { return Promise.resolve({ additions: [], deletions: [], - changes: [] + changes: [], }); } - const additions = this.fileList.additions.filter(file => swagger(file)); - const deletions = this.fileList.deletions.filter(file => swagger(file)); - const changes = this.fileList.modifications.filter(file => swagger(file)); + const additions = this.fileList.additions.filter((file) => swagger(file)); + const deletions = this.fileList.deletions.filter((file) => swagger(file)); + const changes = this.fileList.modifications.filter((file) => swagger(file)); return Promise.resolve({ additions, deletions, - changes + changes, }); } @@ -406,127 +416,136 @@ export class PRContext { return Promise.resolve({ additions: [], deletions: [], - changes: [] + changes: [], }); } - const additions = this.fileList.additions.filter(file => example(file)); - const deletions = this.fileList.deletions.filter(file => example(file)); - const changes = this.fileList.modifications.filter(file => example(file)); + const additions = this.fileList.additions.filter((file) => example(file)); + const deletions = this.fileList.deletions.filter((file) => example(file)); + const changes = this.fileList.modifications.filter((file) => example(file)); return Promise.resolve({ additions, deletions, - changes + changes, }); } async getTagsFromReadme(readmePath: string): Promise { - const tags = await new Readme(readmePath).getTags() ; - return [...tags.values()].map(tag => tag.name); + const tags = await new Readme(readmePath).getTags(); + return [...tags.values()].map((tag) => tag.name); } async getChangingConfigureFiles(): Promise { - console.log("ENTER definition getChangingConfigureFiles") + console.log("ENTER definition getChangingConfigureFiles"); const changedFiles = await this.getChangedFiles(); console.log(`Detect changes in the PR:\n${JSON.stringify(changedFiles, null, 2)}`); - const readmes = changedFiles.filter(f => readme(f)); - - const visitedFolder = new Set() - changedFiles.filter(f => [".md",".json",".yaml",".yml"].some(p => f.endsWith(p))).forEach( - f => { - let dir = dirname(f) - if (visitedFolder.has(dir)) { - return - } - while (specification(dir)) { - if (visitedFolder.has(dir)) { - break - } - visitedFolder.add(dir) - const possibleReadme = join(dir, "readme.md") - if (fs.existsSync(possibleReadme)) { - if (!readmes.includes(possibleReadme)) { - readmes.push(possibleReadme) - } - break - } - dir = dirname(dir) + const readmes = changedFiles.filter((f) => readme(f)); + + const visitedFolder = new Set(); + changedFiles + .filter((f) => [".md", ".json", ".yaml", ".yml"].some((p) => f.endsWith(p))) + .forEach((f) => { + let dir = dirname(f); + if (visitedFolder.has(dir)) { + return; + } + while (specification(dir)) { + if (visitedFolder.has(dir)) { + break; + } + visitedFolder.add(dir); + const possibleReadme = join(dir, "readme.md"); + if (fs.existsSync(possibleReadme)) { + if (!readmes.includes(possibleReadme)) { + readmes.push(possibleReadme); } + break; + } + dir = dirname(dir); } - ) - console.log("RETURN definition getChangingConfigureFiles") - return readmes + }); + console.log("RETURN definition getChangingConfigureFiles"); + return readmes; } - getAllTags(readMeContent: string):string[] { - const cmd = parseMarkdown(readMeContent); - const allTags = new amd.ReadMeManipulator({ error: (_msg: string) => {}}, new amd.ReadMeBuilder()).getAllTags(cmd) - return [...allTags] + getAllTags(readMeContent: string): string[] { + const cmd = parseMarkdown(readMeContent); + const allTags = new amd.ReadMeManipulator( + { error: (_msg: string) => {} }, + new amd.ReadMeBuilder(), + ).getAllTags(cmd); + return [...allTags]; } - async getInputFiles(readMeContent: string,tag:string) { - const cmd = parseMarkdown(readMeContent); - return amd.getInputFilesForTag(cmd.markDown, tag); + async getInputFiles(readMeContent: string, tag: string) { + const cmd = parseMarkdown(readMeContent); + return amd.getInputFilesForTag(cmd.markDown, tag); } async getChangingTags(): Promise { - // this gets all of the readme diffs. no matter if it's removal - // const allAffectedReadmes: string[] = await this.pr.getChangingConfigureFiles() - // this is retrieving _all_ files from additions, renames, modifications, and deletions. - // then we will check if the file is a readme. - const allAffectedReadmes: string[] = await this.getChangingConfigureFiles(); - console.log(`all affected readme are:`) - console.log(JSON.stringify(allAffectedReadmes,null,2)) - const Diffs: TagDiff[] = [] - for (const readme of allAffectedReadmes) { - const oldReadme = join(this.targetDirectory, readme) - const newReadme = join(this.sourceDirectory, readme) - // As the readme may be not existing , need to check if it's existing. - // we are checking target and source individually, because the readme may not exist in the target branch - const oldTags:string[] = fs.existsSync(oldReadme) ? [...(await new Readme(oldReadme).getTags()).keys()] : [] - const newTags:string[] = fs.existsSync(newReadme) ? [...(await new Readme(newReadme).getTags()).keys()] : [] - const intersect = oldTags.filter(t => newTags.includes(t)).filter(tag => tag !== "all-api-versions") - const insertions = newTags.filter(t => !oldTags.includes(t)) - const deletions = oldTags.filter(t => !newTags.includes(t)) - const differences: TagConfigDiff[] = [] - - // todo: we need to ensure we get ALL effected swaggers by their relationships, not just the swagger files that are directly changed in the PR. - // right now I'm just going to filter to the ones that are directly changed in the PR. - // this is a temporary solution, we need to ensure we get all of the swagger files - // that are affected by the changes in the readme. SpecModel will be useful for this - // we want to get all of the swagger files that are affected by the changes in the readme. - // we can do that using the specmodel - // const allAffectedInputFiles = await this.getRealAffectedSwagger(readme) - // talk to Mike and ask him how we could get all affected swagger files from a readme path. - // I want to say that readme(readme).specModel.getAffectedSwaggerFiles will work? - const allAffectedInputFiles = await (await this.getChangedFiles()).filter(f => swagger(f)); - console.log(`all affected swagger files in ${readme} are:`) - console.log(JSON.stringify(allAffectedInputFiles,null,2)) - const getChangedInputFiles = async (tag: string) => { - const readmeContent = await fs.promises.readFile(newReadme, "utf-8"); - const inputFiles = await this.getInputFiles(readmeContent, tag); - if (inputFiles) { - const changedInputFiles = (inputFiles as string[]).filter(f => - allAffectedInputFiles.some(a => a.endsWith(f)) - ); - return changedInputFiles; - } - return []; - } - const changes :string[] = [] - for (const tag of intersect) { - const tagDiff:TagConfigDiff = {name:tag} - const changedInputFiles = await getChangedInputFiles(tag) - if (changedInputFiles.length) { - console.log("found changed input files under tag:" + tag) - tagDiff.changedInputFiles = changedInputFiles - changes.push(tag) - } - } - Diffs.push({readme,insertions,deletions,changes,differences}) + // this gets all of the readme diffs. no matter if it's removal + // const allAffectedReadmes: string[] = await this.pr.getChangingConfigureFiles() + // this is retrieving _all_ files from additions, renames, modifications, and deletions. + // then we will check if the file is a readme. + const allAffectedReadmes: string[] = await this.getChangingConfigureFiles(); + console.log(`all affected readme are:`); + console.log(JSON.stringify(allAffectedReadmes, null, 2)); + const Diffs: TagDiff[] = []; + for (const readme of allAffectedReadmes) { + const oldReadme = join(this.targetDirectory, readme); + const newReadme = join(this.sourceDirectory, readme); + // As the readme may be not existing , need to check if it's existing. + // we are checking target and source individually, because the readme may not exist in the target branch + const oldTags: string[] = fs.existsSync(oldReadme) + ? [...(await new Readme(oldReadme).getTags()).keys()] + : []; + const newTags: string[] = fs.existsSync(newReadme) + ? [...(await new Readme(newReadme).getTags()).keys()] + : []; + const intersect = oldTags + .filter((t) => newTags.includes(t)) + .filter((tag) => tag !== "all-api-versions"); + const insertions = newTags.filter((t) => !oldTags.includes(t)); + const deletions = oldTags.filter((t) => !newTags.includes(t)); + const differences: TagConfigDiff[] = []; + + // todo: we need to ensure we get ALL effected swaggers by their relationships, not just the swagger files that are directly changed in the PR. + // right now I'm just going to filter to the ones that are directly changed in the PR. + // this is a temporary solution, we need to ensure we get all of the swagger files + // that are affected by the changes in the readme. SpecModel will be useful for this + // we want to get all of the swagger files that are affected by the changes in the readme. + // we can do that using the specmodel + // const allAffectedInputFiles = await this.getRealAffectedSwagger(readme) + // talk to Mike and ask him how we could get all affected swagger files from a readme path. + // I want to say that readme(readme).specModel.getAffectedSwaggerFiles will work? + const allAffectedInputFiles = await (await this.getChangedFiles()).filter((f) => swagger(f)); + console.log(`all affected swagger files in ${readme} are:`); + console.log(JSON.stringify(allAffectedInputFiles, null, 2)); + const getChangedInputFiles = async (tag: string) => { + const readmeContent = await fs.promises.readFile(newReadme, "utf-8"); + const inputFiles = await this.getInputFiles(readmeContent, tag); + if (inputFiles) { + const changedInputFiles = (inputFiles as string[]).filter((f) => + allAffectedInputFiles.some((a) => a.endsWith(f)), + ); + return changedInputFiles; + } + return []; + }; + const changes: string[] = []; + for (const tag of intersect) { + const tagDiff: TagConfigDiff = { name: tag }; + const changedInputFiles = await getChangedInputFiles(tag); + if (changedInputFiles.length) { + console.log("found changed input files under tag:" + tag); + tagDiff.changedInputFiles = changedInputFiles; + changes.push(tag); + } } - return Diffs + Diffs.push({ readme, insertions, deletions, changes, differences }); + } + return Diffs; } // this function is based upon LocalDirContext.getReadmeDiffs() and CommonPRContext.getReadmeDiffs() which are @@ -539,7 +558,7 @@ export class PRContext { //const changedFiles: DiffFileResult | undefined = await this.localPRContext?.getChangingFiles(); const changedFiles = await this.fileList; - const tagDiffs = await this.getChangingTags() || []; + const tagDiffs = (await this.getChangingTags()) || []; const readmeTagDiffs = tagDiffs ?.filter((tagDiff: TagDiff) => readme(tagDiff.readme)) @@ -556,41 +575,44 @@ export class PRContext { const readmeTagDiffsInAddedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( (readmeTag: ReadmeTag): boolean => - Boolean(changedFiles?.additions.includes(readmeTag.readme)) + Boolean(changedFiles?.additions.includes(readmeTag.readme)), ); const readmeTagDiffsInDeletedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( (readmeTag: ReadmeTag): boolean => - Boolean(changedFiles?.deletions.includes(readmeTag.readme)) + Boolean(changedFiles?.deletions.includes(readmeTag.readme)), ); const readmeTagDiffsInChangedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( (readmeTag: ReadmeTag): boolean => { - // The README file that contains the API version tags that have been diffed in given readmeTag (i.e. readmeTag.tags) // has been modified. - const readmeModified = changedFiles?.modifications.includes(readmeTag.readme) + const readmeModified = changedFiles?.modifications.includes(readmeTag.readme); // The README was not modified, added or deleted; just the specs belonging to the API version tags in the README were modified. // In such case we assume the README is 'changed' in the sense the specs belonging to the API version tags in the README were modified. // The README file itself wasn't modified. // We assume here the README is present in the repository - otherwise it would show up as 'deleted' or would not // appear as readmeTag.readme in any of the readmeTagDiffs. - const readmeUnchanged = !changedFiles?.additions.includes(readmeTag.readme) && !changedFiles?.deletions.includes(readmeTag.readme) + const readmeUnchanged = + !changedFiles?.additions.includes(readmeTag.readme) && + !changedFiles?.deletions.includes(readmeTag.readme); return readmeModified || readmeUnchanged; - } + }, ); const result = { additions: readmeTagDiffsInAddedReadmeFiles, deletions: readmeTagDiffsInDeletedReadmeFiles, changes: readmeTagDiffsInChangedReadmeFiles, - }; - - console.log(`RETURN definition CommonPRContext.getReadmeDiffs. ` - + `changedFiles: ${JSON.stringify(changedFiles)}, ` - + `tagDiffs: ${JSON.stringify(tagDiffs)}, ` - + `readmeTagDiffs: ${JSON.stringify(readmeTagDiffs)}, ` - + `this.readmeDiffs: ${JSON.stringify(result)}.`); + }; + + console.log( + `RETURN definition CommonPRContext.getReadmeDiffs. ` + + `changedFiles: ${JSON.stringify(changedFiles)}, ` + + `tagDiffs: ${JSON.stringify(tagDiffs)}, ` + + `readmeTagDiffs: ${JSON.stringify(readmeTagDiffs)}, ` + + `this.readmeDiffs: ${JSON.stringify(result)}.`, + ); return result; } diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 888f6253f373..1390a8e05d5e 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -9,65 +9,76 @@ import { evaluateImpact } from "../src/runner.js"; // const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); describe("Check Changes", () => { - it("Integration test 35346", async () => { - const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); - const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); + it("Integration test 35346", async () => { + const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); + const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); - const changedFileDetails = await getChangedFilesStatuses({ cwd: sourceDirectory, baseCommitish: "origin/main" }); - const labelContext: LabelContext = { - present: new Set(), - toAdd: new Set(), - toRemove: new Set() - }; + const changedFileDetails = await getChangedFilesStatuses({ + cwd: sourceDirectory, + baseCommitish: "origin/main", + }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set(), + }; - const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { - sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", - sourceBranch: "dev/nandiniy/DTLTypeSpec", - targetBranch: "main", - repo: "azure-rest-api-specs", - prNumber: "35346", - owner: "Azure", - fileList: changedFileDetails, - isDraft: false - }); + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", + sourceBranch: "dev/nandiniy/DTLTypeSpec", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35346", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false, + }); - const result = await evaluateImpact(prContext, labelContext); + const result = await evaluateImpact(prContext, labelContext); - expect(result).toBeDefined(); - expect(result.typeSpecChanged).toBeTruthy(); + expect(result).toBeDefined(); + expect(result.typeSpecChanged).toBeTruthy(); + expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); + expect(changedFileDetails).toBeDefined(); + expect(changedFileDetails.total).toEqual(293); + }, 60000000); - expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); - expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); - expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); - expect(changedFileDetails).toBeDefined(); - expect(changedFileDetails.total).toEqual(293); - }, 60000000); + it.only("Integration test 35982", async () => { + const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); + const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); + const changedFileDetails = await getChangedFilesStatuses({ + cwd: sourceDirectory, + baseCommitish: "origin/main", + }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set(), + }; - it("Integration test 35982", async () => { - const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); - const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "2bd8350d465081401a0f4f03e633eca41f0991de", + sourceBranch: "features/users/deepika/cosmos-connectors-confluent", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35982", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false, + }); - const changedFileDetails = await getChangedFilesStatuses({ cwd: sourceDirectory, baseCommitish: "origin/main" }); - const labelContext: LabelContext = { - present: new Set(), - toAdd: new Set(), - toRemove: new Set() - }; + const result = await evaluateImpact(prContext, labelContext); - const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { - sha: "2bd8350d465081401a0f4f03e633eca41f0991de", - sourceBranch: "features/users/deepika/cosmos-connectors-confluent", - targetBranch: "main", - repo: "azure-rest-api-specs", - prNumber: "35982", - owner: "Azure", - fileList: changedFileDetails, - isDraft: false - }); + // expect ARMReview, new-api-version, resource-manager, RPaaS, TypeSpec, WaitForARMFeedback - const result = await evaluateImpact(prContext, labelContext); - - expect(result).toBeDefined(); - }, 60000000); + expect(result.labelContext.toAdd.has("TypeSpec")).toBeTruthy(); + expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.labelContext.toAdd.has("new-api-version")).toBeTruthy(); + expect(result.labelContext.toAdd.has("RPaaS")).toBeTruthy(); + expect(result).toBeDefined(); + }, 60000000); }); From 2278f923470762533506f8e889b385437882bd6e Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 18 Jul 2025 22:56:26 +0000 Subject: [PATCH 24/67] starting cleanup refactors. only a couple todo to take care of --- .github/shared/src/error-reporting.js | 22 ++++++++ .github/shared/test/error-reporting.test.js | 28 +++++++++- eng/tools/summarize-impact/src/cli.ts | 17 ++++-- .../src/{runner.ts => impact.ts} | 16 +++--- eng/tools/summarize-impact/src/types.ts | 54 ++++++++++--------- eng/tools/summarize-impact/test/cli.test.ts | 2 +- 6 files changed, 97 insertions(+), 42 deletions(-) rename eng/tools/summarize-impact/src/{runner.ts => impact.ts} (98%) diff --git a/.github/shared/src/error-reporting.js b/.github/shared/src/error-reporting.js index 71a71a9eab9d..5eab9575c988 100644 --- a/.github/shared/src/error-reporting.js +++ b/.github/shared/src/error-reporting.js @@ -19,6 +19,28 @@ export function setSummary(content) { fs.writeFileSync(summaryFile, content); } +/** + * Set the output for a Github Actions step. The output is written to the GITHUB_OUTPUT environment variable. + * This is used to pass data between steps in a workflow. + * + * To access this output later, leverage: ${{ steps..outputs. }}. + * + * This function is the equivalent of using `core.setOutput(name, value)` in a GitHub Action, without the package dependency. + * @param {string} name - The name of the output variable. + * @param {string} value - The value to set for the output variable. + * @returns {void} + */ +export function setOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) { + console.log(`GITHUB_OUTPUT is not set. Skipping ${name} update with value '${value}.'`); + return; + } + + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } +} + /** * This function is used to ask the github agent to annotate a file in a github PR with an error message. * @param {string} repoPath diff --git a/.github/shared/test/error-reporting.test.js b/.github/shared/test/error-reporting.test.js index 107e2141de71..b89728184734 100644 --- a/.github/shared/test/error-reporting.test.js +++ b/.github/shared/test/error-reporting.test.js @@ -1,7 +1,7 @@ // @ts-check import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { setSummary, annotateFileError } from "../src/error-reporting.js"; +import { setSummary, annotateFileError, setOutput } from "../src/error-reporting.js"; import fs from "fs/promises"; describe("ErrorReporting", () => { @@ -12,13 +12,21 @@ describe("ErrorReporting", () => { // ensure that on test runs GITHUB_STEP_SUMMARY is not set in my current env by default // this gives us a clean slate for each test delete process.env.GITHUB_STEP_SUMMARY; + delete process.env.GITHUB_OUTPUT; }); afterEach(() => { logSpy.mockRestore(); }); - it("should warn when GITHUB_STEP_SUMMARY is unset", () => { + it("should warn when calling setOutput when GITHUB_OUTPUT is unset", () => { + setOutput("test", "value"); + expect(logSpy).toHaveBeenCalledWith( + "GITHUB_OUTPUT is not set. Skipping test update with value 'value.'", + ); + }); + + it("should warn when calling setSummary when GITHUB_STEP_SUMMARY is unset", () => { setSummary("hello"); expect(logSpy).toHaveBeenCalledWith("GITHUB_STEP_SUMMARY is not set. Skipping summary update."); }); @@ -42,6 +50,22 @@ describe("ErrorReporting", () => { expect(content).toBe("# Title"); }); + it("should write an output when GITHUB_OUTPUT is set", async () => { + process.env.GITHUB_OUTPUT = `${__dirname}/tmp-output.txt`; + + setOutput("test", "value"); + + expect(logSpy).not.toHaveBeenCalledWith( + "GITHUB_OUTPUT is not set. Skipping test update with value 'value.'", + ); + + const content = await fs.readFile(process.env.GITHUB_OUTPUT, "utf-8"); + expect(content).toBe("test=value\n"); + + // cleanup after the test so nothing is left behind + await fs.unlink(process.env.GITHUB_OUTPUT); + }); + it("should emit a GitHub-style error annotation", () => { annotateFileError("src/foo.js", "Something broke", 42, 7); expect(logSpy).toHaveBeenCalledWith("::error file=src/foo.js,line=42,col=7::Something broke"); diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 0cb0e69c4189..7a6b72928193 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -1,9 +1,11 @@ #!/usr/bin/env node -import { evaluateImpact } from "./runner.js"; +import { evaluateImpact } from "./impact.js"; import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files"; +import { setOutput } from "@azure-tools/specs-shared/error-reporting"; -import { resolve } from "path"; +import { resolve, join } from "path"; +import fs from "fs"; import { parseArgs, ParseArgsConfig } from "node:util"; import { simpleGit } from "simple-git"; import { LabelContext, PRContext } from "./types.js"; @@ -86,7 +88,7 @@ export async function main() { const { values: opts } = parseArgs(config); - // todo: refactor these opts? They're not really options. I just don't want a bunch of positional args for clarity's sake + // todo: refactor these opts const sourceDirectory = opts.sourceDirectory as string; const targetDirectory = opts.targetDirectory as string; const sourceGitRoot = await getRootFolder(sourceDirectory); @@ -118,5 +120,12 @@ export async function main() { isDraft, }); - evaluateImpact(prContext, labelContext); + let impact = evaluateImpact(prContext, labelContext); + + console.log("Evaluated impact: ", JSON.stringify(impact)); + + // Write to a temp file that can get picked up later. + const summaryFile = join(process.cwd(), "summary.json"); + fs.writeFileSync(summaryFile, JSON.stringify(impact, null, 2)); + setOutput("summary", summaryFile); } diff --git a/eng/tools/summarize-impact/src/runner.ts b/eng/tools/summarize-impact/src/impact.ts similarity index 98% rename from eng/tools/summarize-impact/src/runner.ts rename to eng/tools/summarize-impact/src/impact.ts index ee7d81f4223e..6b5408dd4d7e 100644 --- a/eng/tools/summarize-impact/src/runner.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -105,12 +105,10 @@ export async function evaluateImpact( // Has to run in PR context. // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present - // const { - // versioningReviewRequiredLabelShouldBePresent, - // breakingChangeReviewRequiredLabelShouldBePresent, - // } = await processBreakingChangeLabels(context, labelContext); - const versioningReviewRequiredLabelShouldBePresent = false; - const breakingChangeReviewRequiredLabelShouldBePresent = false; + const { + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + } = await processBreakingChangeLabels(context, labelContext); // needs to examine "after" context to understand if a readme that was changed is RPaaS or not const { rpaasLabelShouldBePresent } = await processRPaaS(context, labelContext); @@ -502,9 +500,9 @@ export async function getPRChanges(ctx: PRContext): Promise { } } - genChanges("SwaggerFile", await ctx.getSwaggerDiffs()); - genChanges("TypeSpecFile", await ctx.getTypeSpecDiffs()); - genChanges("ExampleFile", await ctx.getExampleDiffs()); + genChanges("SwaggerFile", ctx.getSwaggerDiffs()); + genChanges("TypeSpecFile", ctx.getTypeSpecDiffs()); + genChanges("ExampleFile", ctx.getExampleDiffs()); genReadmeChanges(await ctx.getReadmeDiffs()); console.log("RETURN definition getPRChanges"); diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index ad8e9fe74c0b..5a3fc3cf21c2 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -3,7 +3,6 @@ import * as amd from "@azure/openapi-markdown"; import { parseMarkdown } from "@azure-tools/openapi-tools-common"; import * as fs from "fs"; -// import { Readme } from "@azure-tools/specs-shared/readme"; import { SpecModel } from "@azure-tools/specs-shared/spec-model"; import { swagger, @@ -11,7 +10,7 @@ import { example, readme, specification, -} from "@azure-tools/specs-shared/changed-files"; //readme, +} from "@azure-tools/specs-shared/changed-files"; import { Readme } from "@azure-tools/specs-shared/readme"; export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; @@ -357,7 +356,7 @@ export class PRContext { this.owner = options.owner; } - async getChangedFiles(): Promise { + getChangedFiles(): string[] { if (!this.fileList) { return []; } @@ -371,64 +370,64 @@ export class PRContext { // todo get rid of async here not necessary // todo store the results - async getTypeSpecDiffs(): Promise> { + getTypeSpecDiffs(): DiffResult { if (!this.fileList) { - return Promise.resolve({ + return { additions: [], deletions: [], changes: [], - }); + }; } const additions = this.fileList.additions.filter((file) => typespec(file)); const deletions = this.fileList.deletions.filter((file) => typespec(file)); const changes = this.fileList.modifications.filter((file) => typespec(file)); - return Promise.resolve({ + return { additions, deletions, changes, - }); + }; } - async getSwaggerDiffs(): Promise> { + getSwaggerDiffs(): DiffResult { if (!this.fileList) { - return Promise.resolve({ + return { additions: [], deletions: [], changes: [], - }); + }; } const additions = this.fileList.additions.filter((file) => swagger(file)); const deletions = this.fileList.deletions.filter((file) => swagger(file)); const changes = this.fileList.modifications.filter((file) => swagger(file)); - return Promise.resolve({ + return { additions, deletions, changes, - }); + }; } - async getExampleDiffs(): Promise> { + getExampleDiffs(): DiffResult { if (!this.fileList) { - return Promise.resolve({ + return { additions: [], deletions: [], changes: [], - }); + }; } const additions = this.fileList.additions.filter((file) => example(file)); const deletions = this.fileList.deletions.filter((file) => example(file)); const changes = this.fileList.modifications.filter((file) => example(file)); - return Promise.resolve({ + return { additions, deletions, changes, - }); + }; } async getTagsFromReadme(readmePath: string): Promise { @@ -436,8 +435,8 @@ export class PRContext { return [...tags.values()].map((tag) => tag.name); } - async getChangingConfigureFiles(): Promise { - console.log("ENTER definition getChangingConfigureFiles"); + async getPossibleParentConfigurations(): Promise { + console.log("ENTER definition getPossibleParentConfigurations"); const changedFiles = await this.getChangedFiles(); console.log(`Detect changes in the PR:\n${JSON.stringify(changedFiles, null, 2)}`); const readmes = changedFiles.filter((f) => readme(f)); @@ -465,11 +464,13 @@ export class PRContext { dir = dirname(dir); } }); - console.log("RETURN definition getChangingConfigureFiles"); + console.log("RETURN definition getPossibleParentConfigurations"); return readmes; } getAllTags(readMeContent: string): string[] { + // todo: we should refactor this to use the spec model, but I haven't had the the time to explicitly + // diff what oad does does here, so I'm leaving it alone for now until I can build some strong unit tests around this. const cmd = parseMarkdown(readMeContent); const allTags = new amd.ReadMeManipulator( { error: (_msg: string) => {} }, @@ -479,16 +480,17 @@ export class PRContext { } async getInputFiles(readMeContent: string, tag: string) { + // todo: we should refactor this to use spec model, but I haven't had time to isolate exactly what + // openapi-markdown is doing here, so I'm just going to use the same logic for now const cmd = parseMarkdown(readMeContent); return amd.getInputFilesForTag(cmd.markDown, tag); } async getChangingTags(): Promise { - // this gets all of the readme diffs. no matter if it's removal - // const allAffectedReadmes: string[] = await this.pr.getChangingConfigureFiles() - // this is retrieving _all_ files from additions, renames, modifications, and deletions. - // then we will check if the file is a readme. - const allAffectedReadmes: string[] = await this.getChangingConfigureFiles(); + // we are retrieving all the readme changes, no matter if they're additions, deletions, etc + // Additionally, we're also retrieving all the readme files that may be affected by the changes in the PR, which means + // climbing up the directory tree until we find a readme.md file if necessary. + const allAffectedReadmes: string[] = await this.getPossibleParentConfigurations(); console.log(`all affected readme are:`); console.log(JSON.stringify(allAffectedReadmes, null, 2)); const Diffs: TagDiff[] = []; diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 1390a8e05d5e..09a502fbf93f 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -4,7 +4,7 @@ import path from "path"; import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files"; import { PRContext, LabelContext } from "../src/types.js"; -import { evaluateImpact } from "../src/runner.js"; +import { evaluateImpact } from "../src/impact.js"; // const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); From 5ae3ef50e2f05ba53295caaa200b7f3a91996b08 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:02:16 +0000 Subject: [PATCH 25/67] we're actually properly processing now. we should be able to publish properly. let's check that! --- eng/tools/summarize-impact/src/impact.ts | 5 +- eng/tools/summarize-impact/test/cli.test.ts | 139 +++++++++++--------- 2 files changed, 80 insertions(+), 64 deletions(-) diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index 6b5408dd4d7e..2cf45cfbe349 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -24,8 +24,8 @@ import { } from "./types.js"; import { Readme } from "@azure-tools/specs-shared/readme"; -export declare const breakingChangeLabelVarName = "breakingChangeVar"; -export declare const crossVersionBreakingChangeLabelVarName = "crossVersionBreakingChangeVar"; +export const breakingChangeLabelVarName = "breakingChangeVar"; +export const crossVersionBreakingChangeLabelVarName = "crossVersionBreakingChangeVar"; // todo: we need to populate this so that we can tell if it's a new APIVersion down stream export async function isNewApiVersion(context: PRContext): Promise { @@ -103,7 +103,6 @@ export async function evaluateImpact( const suppressionRequired = await processSuppression(context, labelContext); console.log(`suppressionRequired: ${suppressionRequired}`); - // Has to run in PR context. // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present const { versioningReviewRequiredLabelShouldBePresent, diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 09a502fbf93f..0540ab30da81 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -13,72 +13,89 @@ describe("Check Changes", () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); - const changedFileDetails = await getChangedFilesStatuses({ - cwd: sourceDirectory, - baseCommitish: "origin/main", - }); - const labelContext: LabelContext = { - present: new Set(), - toAdd: new Set(), - toRemove: new Set(), - }; - - const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { - sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", - sourceBranch: "dev/nandiniy/DTLTypeSpec", - targetBranch: "main", - repo: "azure-rest-api-specs", - prNumber: "35346", - owner: "Azure", - fileList: changedFileDetails, - isDraft: false, - }); - - const result = await evaluateImpact(prContext, labelContext); - - expect(result).toBeDefined(); - expect(result.typeSpecChanged).toBeTruthy(); - expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); - expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); - expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); - expect(changedFileDetails).toBeDefined(); - expect(changedFileDetails.total).toEqual(293); + // Change to source directory and save original + const originalCwd = process.cwd(); + process.chdir(sourceDirectory); + + try { + const changedFileDetails = await getChangedFilesStatuses({ + cwd: sourceDirectory, + baseCommitish: "origin/main", + }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set(), + }; + + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", + sourceBranch: "dev/nandiniy/DTLTypeSpec", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35346", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false, + }); + + const result = await evaluateImpact(prContext, labelContext); + + expect(result).toBeDefined(); + expect(result.typeSpecChanged).toBeTruthy(); + expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); + expect(changedFileDetails).toBeDefined(); + expect(changedFileDetails.total).toEqual(293); + } finally { + // Restore original directory + process.chdir(originalCwd); + } }, 60000000); it.only("Integration test 35982", async () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); - const changedFileDetails = await getChangedFilesStatuses({ - cwd: sourceDirectory, - baseCommitish: "origin/main", - }); - const labelContext: LabelContext = { - present: new Set(), - toAdd: new Set(), - toRemove: new Set(), - }; - - const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { - sha: "2bd8350d465081401a0f4f03e633eca41f0991de", - sourceBranch: "features/users/deepika/cosmos-connectors-confluent", - targetBranch: "main", - repo: "azure-rest-api-specs", - prNumber: "35982", - owner: "Azure", - fileList: changedFileDetails, - isDraft: false, - }); - - const result = await evaluateImpact(prContext, labelContext); - - // expect ARMReview, new-api-version, resource-manager, RPaaS, TypeSpec, WaitForARMFeedback - - expect(result.labelContext.toAdd.has("TypeSpec")).toBeTruthy(); - expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); - expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); - expect(result.labelContext.toAdd.has("new-api-version")).toBeTruthy(); - expect(result.labelContext.toAdd.has("RPaaS")).toBeTruthy(); - expect(result).toBeDefined(); + // Change to source directory and save original + const originalCwd = process.cwd(); + process.chdir(sourceDirectory); + + try { + const changedFileDetails = await getChangedFilesStatuses({ + cwd: sourceDirectory, + baseCommitish: "origin/main", + }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set(), + }; + + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "2bd8350d465081401a0f4f03e633eca41f0991de", + sourceBranch: "features/users/deepika/cosmos-connectors-confluent", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35982", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false, + }); + + const result = await evaluateImpact(prContext, labelContext); + + // expect ARMReview, new-api-version, resource-manager, RPaaS, TypeSpec, WaitForARMFeedback + + expect(result.isNewApiVersion).toBeTruthy(); + expect(result.labelContext.toAdd.has("TypeSpec")).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.labelContext.toAdd.has("RPaaS")).toBeTruthy(); + expect(result).toBeDefined(); + } finally { + // Restore original directory + process.chdir(originalCwd); + } }, 60000000); }); From e5123fee2197fbbcf4fb7a349b983e5e233a44fe Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:04:51 +0000 Subject: [PATCH 26/67] merge readme changes --- eng/tools/summarize-impact/README.md | 31 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/eng/tools/summarize-impact/README.md b/eng/tools/summarize-impact/README.md index 1ee7d526e4fe..93f763790b97 100644 --- a/eng/tools/summarize-impact/README.md +++ b/eng/tools/summarize-impact/README.md @@ -4,16 +4,21 @@ This tool models the PR and produces an artifact that is consumed by the `summar The schema of the artifact looks like: -```json -{ - "pr-type": ["resource-manager", "data-plane"], - "suppressionsChanged": true, - "versioningReviewRequired": false, - "breakingChangeReviewRequired": true, - "rpaasChange": true, - "newRP": true, - "rpaasRPMissing": true -} -``` - -## Invocation +```typescript +export type ImpactAssessment = { + prType: string[]; + resourceManagerRequired: boolean; + suppressionReviewRequired: boolean; + versioningReviewRequired: boolean; + breakingChangeReviewRequired: boolean; + isNewApiVersion: boolean; + rpaasExceptionRequired: boolean; + rpaasRpNotInPrivateRepo: boolean; + rpaasChange: boolean; + newRP: boolean; + rpaasRPMissing: boolean; + typeSpecChanged: boolean; + isDraft: boolean; + labelContext: LabelContext; +}; +``` \ No newline at end of file From 21ba5dda5436d62f0d58c5b9f9848ec3a39184d1 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:11:42 +0000 Subject: [PATCH 27/67] reflect necessary updates to package lock --- package-lock.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index 65b364e2d172..74cc3198aaae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -469,7 +469,9 @@ "name": "@azure-tools/summarize-impact", "dev": true, "dependencies": { + "@azure-tools/openapi-tools-common": "^1.2.2", "@azure-tools/specs-shared": "file:../../../.github/shared", + "@azure/openapi-markdown": "0.9.4", "@ts-common/commonmark-to-markdown": "^2.0.2", "commonmark": "^0.29.0", "glob": "latest", From 485b851ac3e2815d2d06e079fd7c655bd1290458 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:15:43 +0000 Subject: [PATCH 28/67] build explicitly --- .github/workflows/summarize-impact.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index b84d0567b106..8d44fcab034c 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -34,6 +34,12 @@ jobs: - name: Setup Node and install deps uses: ./.github/actions/setup-node-install-deps + # todo: why is this necessary? postinstall should already take care of the build? + - name: Build summarize-impact tool + run: | + cd eng/tools/summarize-impact + npm run build + - name: Get PR labels id: get-labels run: | From d256ca9a8d3211a7baead159dc2c950947d2f4a2 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:21:48 +0000 Subject: [PATCH 29/67] install glob --- eng/tools/summarize-impact/package.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/eng/tools/summarize-impact/package.json b/eng/tools/summarize-impact/package.json index 802c34089b1f..83e013aa8257 100644 --- a/eng/tools/summarize-impact/package.json +++ b/eng/tools/summarize-impact/package.json @@ -15,23 +15,24 @@ "test:ci": "vitest run --coverage --reporter=verbose" }, "dependencies": { - "@azure-tools/specs-shared": "file:../../../.github/shared", "@azure-tools/openapi-tools-common": "^1.2.2", + "@azure-tools/specs-shared": "file:../../../.github/shared", "@azure/openapi-markdown": "0.9.4", - "glob": "latest", "@ts-common/commonmark-to-markdown": "^2.0.2", "commonmark": "^0.29.0", + "glob": "^11.0.3", "js-yaml": "^4.1.0", "lodash": "^4.17.20", "simple-git": "^3.27.0" }, "devDependencies": { + "@types/commonmark": "^0.27.4", + "@types/glob": "^8.1.0", + "@types/lodash": "^4.14.161", "@types/node": "^20.0.0", "prettier": "~3.5.3", - "@types/lodash": "^4.14.161", "typescript": "~5.8.2", - "vitest": "^3.0.7", - "@types/commonmark": "^0.27.4" + "vitest": "^3.0.7" }, "engines": { "node": ">=20.0.0" From afbfc80da3674c77974358949ac30eaee3c3b2b0 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:24:24 +0000 Subject: [PATCH 30/67] build this thing --- package-lock.json | 88 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 74cc3198aaae..65a9450294df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -239,6 +239,46 @@ "dev": true, "license": "MIT" }, + "eng/tools/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "eng/tools/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "eng/tools/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -246,6 +286,16 @@ "dev": true, "license": "MIT" }, + "eng/tools/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "eng/tools/node_modules/marked": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-16.0.0.tgz", @@ -275,6 +325,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "eng/tools/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "eng/tools/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -474,7 +541,7 @@ "@azure/openapi-markdown": "0.9.4", "@ts-common/commonmark-to-markdown": "^2.0.2", "commonmark": "^0.29.0", - "glob": "latest", + "glob": "^11.0.3", "js-yaml": "^4.1.0", "lodash": "^4.17.20", "simple-git": "^3.27.0" @@ -484,6 +551,7 @@ }, "devDependencies": { "@types/commonmark": "^0.27.4", + "@types/glob": "^8.1.0", "@types/lodash": "^4.14.161", "@types/node": "^20.0.0", "prettier": "~3.5.3", @@ -4700,6 +4768,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -4728,6 +4807,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", From f4e73fc1e6ea603fda347fa90f6ccf16bd9f672b Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:27:43 +0000 Subject: [PATCH 31/67] we can't use short refs that are more than single character --- .github/workflows/summarize-impact.yaml | 4 ++-- eng/tools/summarize-impact/src/cli.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 8d44fcab034c..2a4e98e0e651 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -53,8 +53,8 @@ jobs: run: | npm exec --no -- summarize-impact --sourceDirectory after --targetDirectory before \ --number "${{ github.event.pull_request.number }}" \ - --sb "${{ github.event.pull_request.head.ref }}" \ - --tb "${{ github.event.pull_request.base.ref }}" \ + --sourceBranch "${{ github.event.pull_request.head.ref }}" \ + --targetBranch "${{ github.event.pull_request.base.ref }}" \ --sha "${{ github.event.pull_request.head.sha }}" \ --repo "${{ github.repository }}" \ --owner "${{ github.repository_owner }}" \ diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 7a6b72928193..c30f0625f669 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -50,12 +50,10 @@ export async function main() { }, sourceBranch: { type: "string", - short: "sb", multiple: false, }, targetBranch: { type: "string", - short: "tb", multiple: false, }, sha: { From 31e59b084c43a21d455d556ec68d559842079349 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:30:43 +0000 Subject: [PATCH 32/67] we need to run in context of the checked out directory after --- .github/workflows/summarize-impact.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 2a4e98e0e651..7a889431c368 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -50,6 +50,7 @@ jobs: - name: Run Summarize Impact id: summarize-impact + working-directory: after run: | npm exec --no -- summarize-impact --sourceDirectory after --targetDirectory before \ --number "${{ github.event.pull_request.number }}" \ From 07a8ed1905750a1fa0e5f6c67e028cd4463344cc Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:35:18 +0000 Subject: [PATCH 33/67] need to make certain that the paths will resolve --- .github/workflows/summarize-impact.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 7a889431c368..bd0b442ccd31 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -52,7 +52,7 @@ jobs: id: summarize-impact working-directory: after run: | - npm exec --no -- summarize-impact --sourceDirectory after --targetDirectory before \ + npm exec --no -- summarize-impact --sourceDirectory . --targetDirectory ../before \ --number "${{ github.event.pull_request.number }}" \ --sourceBranch "${{ github.event.pull_request.head.ref }}" \ --targetBranch "${{ github.event.pull_request.base.ref }}" \ From 7993026f6bd61139ec2e00a2c2bf896ee5684a60 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:43:18 +0000 Subject: [PATCH 34/67] a couple updates for the output --- eng/tools/summarize-impact/src/impact.ts | 7 ++++++- eng/tools/summarize-impact/test/cli.test.ts | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index 2cf45cfbe349..03dcaab29434 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -240,7 +240,9 @@ export const getResourceProviderFromFilePath = (filePath: string): string | unde return undefined; }; -// todo: is this right? +// todo: download the labels from the breaking change check run +// right now this logic is coded to parse the ADO build variables that are set +// by the breakingchange run in an earlier job. async function processBreakingChangeLabels( prContext: PRContext, labelContext: LabelContext, @@ -248,6 +250,7 @@ async function processBreakingChangeLabels( versioningReviewRequiredLabelShouldBePresent: boolean; breakingChangeReviewRequiredLabelShouldBePresent: boolean; }> { + console.log("ENTER definition processBreakingChangeLabels"); const prTargetsProductionBranch: boolean = checkPrTargetsProductionBranch(prContext); const breakingChangesLabelsFromOad = getBreakingChangesLabelsFromOad(); @@ -607,6 +610,8 @@ async function processSuppression(context: PRContext, labelContext: LabelContext suppressionReviewRequiredLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); console.log("RETURN definition processSuppression"); + + return suppressionReviewRequiredLabel.shouldBePresent; } function getSuppressions(readmePath: string) { diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 0540ab30da81..c027cc1895c5 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -9,7 +9,7 @@ import { evaluateImpact } from "../src/impact.js"; // const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); describe("Check Changes", () => { - it("Integration test 35346", async () => { + it.only("Integration test 35346", async () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); @@ -43,7 +43,6 @@ describe("Check Changes", () => { expect(result).toBeDefined(); expect(result.typeSpecChanged).toBeTruthy(); - expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); expect(changedFileDetails).toBeDefined(); @@ -54,7 +53,7 @@ describe("Check Changes", () => { } }, 60000000); - it.only("Integration test 35982", async () => { + it("Integration test 35982", async () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); From eb96802280e4b5e0b03f175c748110d65ff49494 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:52:26 +0000 Subject: [PATCH 35/67] adding a bunch of logging and surrounding the crashing function in try/catch --- eng/tools/summarize-impact/src/impact.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index 03dcaab29434..b3a5687e2b98 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -104,10 +104,16 @@ export async function evaluateImpact( console.log(`suppressionRequired: ${suppressionRequired}`); // Calculates whether or not BreakingChangeReviewRequired and VersioningReviewRequired labels should be present - const { - versioningReviewRequiredLabelShouldBePresent, - breakingChangeReviewRequiredLabelShouldBePresent, - } = await processBreakingChangeLabels(context, labelContext); + let versioningReviewRequiredLabelShouldBePresent: boolean = false; + let breakingChangeReviewRequiredLabelShouldBePresent: boolean = false; + try { + ({ + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + } = await processBreakingChangeLabels(context, labelContext)); + } catch (error) { + console.error("Error processing breaking change labels:", error); + } // needs to examine "after" context to understand if a readme that was changed is RPaaS or not const { rpaasLabelShouldBePresent } = await processRPaaS(context, labelContext); @@ -251,6 +257,12 @@ async function processBreakingChangeLabels( breakingChangeReviewRequiredLabelShouldBePresent: boolean; }> { console.log("ENTER definition processBreakingChangeLabels"); + + // Debug the breakingChangesCheckType import + console.log("breakingChangesCheckType:", breakingChangesCheckType); + console.log("breakingChangesCheckType.SameVersion:", breakingChangesCheckType?.SameVersion); + console.log("breakingChangesCheckType.CrossVersion:", breakingChangesCheckType?.CrossVersion); + const prTargetsProductionBranch: boolean = checkPrTargetsProductionBranch(prContext); const breakingChangesLabelsFromOad = getBreakingChangesLabelsFromOad(); From d067bfe36df9b030a46d5f072296944fd7a51ee6 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sat, 19 Jul 2025 00:57:22 +0000 Subject: [PATCH 36/67] evaluate that impact! --- eng/tools/summarize-impact/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index c30f0625f669..571a095277c2 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -118,7 +118,7 @@ export async function main() { isDraft, }); - let impact = evaluateImpact(prContext, labelContext); + let impact = await evaluateImpact(prContext, labelContext); console.log("Evaluated impact: ", JSON.stringify(impact)); From 3178897a0a50add6dfadfa97a44c5797db5df554 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 20:02:56 +0000 Subject: [PATCH 37/67] fix summarize checks yaml. ensure we don't fail to get ratelimit (because it makes no sense to get rate limit for builtin identity) --- .../src/summarize-checks/summarize-checks.js | 16 ---------------- .github/workflows/summarize-checks.yaml | 4 ++-- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 68215c12acb7..e01cf8987768 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -275,7 +275,6 @@ const EXCLUDED_CHECK_NAMES = []; * @returns {Promise} */ export default async function summarizeChecks({ github, context, core }) { - logGitHubRateLimitInfo(github, core); let { owner, repo, issue_number, head_sha } = await extractInputs(github, context, core); const targetBranch = context.payload.pull_request?.base?.ref; core.info(`PR target branch: ${targetBranch}`); @@ -424,21 +423,6 @@ export async function summarizeChecksImpl( // ) } -/** - * @param {import('@actions/github-script').AsyncFunctionArguments['github']} github - * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - * @returns {Promise} - */ -export async function logGitHubRateLimitInfo(github, core) { - try { - const { data: rateLimit } = await github.rest.rateLimit.get(); - const { data: user } = await github.rest.users.getAuthenticated(); - core.info(`GitHub RateLimit Info for user ${user.login}: ${JSON.stringify(rateLimit)}`); - } catch (e) { - core.error(`GitHub RateLimit Info: error emitting. Exception: ${e}`); - } -} - /** * A GraphQL query to GitHub API that returns all check runs for given commit, with "isRequired" field for given PR. * diff --git a/.github/workflows/summarize-checks.yaml b/.github/workflows/summarize-checks.yaml index d884fd4bb416..f81cde86b7c5 100644 --- a/.github/workflows/summarize-checks.yaml +++ b/.github/workflows/summarize-checks.yaml @@ -6,10 +6,10 @@ on: workflows: - "\\[TEST-IGNORE\\] Swagger SemanticValidation - Set Status" - "\\[TEST-IGNORE\\] Swagger ModelValidation - Set Status" + - "\\[TEST-IGNORE\\] Summarize PR Impact" - "Swagger Avocado - Set Status" - "Swagger LintDiff - Set Status" - "SDK Validation Status" - - ["\\[TEST-IGNORE\\] Summarize PR Impact"] types: - completed pull_request_target: # when a PR is labeled. NOT pull_request, because that would run on the PR branch, not the base branch. @@ -25,7 +25,7 @@ permissions: contents: read # for actions/checkout checks: read # for octokit.rest.checks.listForRef statuses: read # for octokit.rest.repos.getCombinedStatusForRef - issues: write # to post comments via the Issues API I'm not certain if I need this one or pull-requests only. we'll find out! + issues: write # to post comments via the Issues API pull-requests: write # to post comments via the Pull Requests API jobs: From cda9c5ed8ace2b6b56cae0a68c06060d35f1379f Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 21:08:11 +0000 Subject: [PATCH 38/67] oodles more updates! time to close the loop between the two checks! --- .../src/summarize-checks/labelling.js | 356 +++++++++++-- .../src/summarize-checks/summarize-checks.js | 474 ++---------------- eng/tools/summarize-impact/src/impact.ts | 2 +- eng/tools/summarize-impact/src/types.ts | 2 +- 4 files changed, 378 insertions(+), 456 deletions(-) diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index b92334578992..84fdf4fa3a40 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -12,8 +12,6 @@ import { wrapInArmReviewMessage, } from "./tsgs.js"; -import { readFileSync } from "fs"; - // #region typedefs /** * The LabelContext is used by the updateLabels() to determine which labels to add or remove to the PR. @@ -46,6 +44,24 @@ import { readFileSync } from "fs"; * @property {Set} toRemove - The set of labels to remove */ +/** + * @typedef {Object} ImpactAssessment + * @property {boolean} resourceManagerRequired - Whether a resource manager review is required. + * @property {boolean} suppressionReviewRequired - Whether a suppression review is required. + * @property {boolean} versioningReviewRequired - Whether a versioning policy review is required. + * @property {boolean} breakingChangeReviewRequired - Whether a breaking change review is required. + * @property {boolean} isNewApiVersion - Whether this PR introduces a new API version. + * @property {boolean} rpaasExceptionRequired - Whether an RPaaS exception is required. + * @property {boolean} rpaasRpNotInPrivateRepo - Whether the RPaaS RP is not present in the private repo. + * @property {boolean} rpaasChange - Whether this PR includes RPaaS changes. + * @property {boolean} newRP - Whether this PR introduces a new resource provider. + * @property {boolean} rpaasRPMissing - Whether the RPaaS RP label is missing. + * @property {boolean} typeSpecChanged - Whether a TypeSpec file has changed. + * @property {boolean} isDraft - Whether the PR is a draft. + * @property {LabelContext} labelContext - The context containing present, to-add, and to-remove labels. + * @property {string} targetBranch - The name of the target branch for the PR. + */ + /** * This file is the single source of truth for the labels used by the SDK generation tooling * in the Azure/azure-rest-api-specs and Azure/azure-rest-api-specs-pr repositories. @@ -378,22 +394,112 @@ export const breakingChangesCheckType = { }; // #endregion constants -// #region LabelContext - - -async function processARMReview( - context: IValidatorContext, - labelContext: LabelContext, - resourceManagerLabelShouldBePresent: boolean, - versioningReviewRequiredLabelShouldBePresent: boolean, - breakingChangeReviewRequiredLabelShouldBePresent: boolean, - ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, - rpaasExceptionLabelShouldBePresent: boolean, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean, - isDraft: boolean, -): Promise<{ armReviewLabelShouldBePresent: boolean }> { +// #region Required Labels + + +/** + * @param {import("./labelling.js").LabelContext} context + * @param {string[]} existingLabels + * @returns {void} + */ +export function processArmReviewLabels( + context, + existingLabels +) { + // the important part about how this will work depends how the users use it + // EG: if they add the "ARMSignedOff" label, we will remove the "ARMChangesRequested" and "WaitForARMFeedback" labels. + // if they add the "ARMChangesRequested" label, we will remove the "WaitForARMFeedback" label. + // if they remove the "ARMChangesRequested" label, we will add the "WaitForARMFeedback" label. + // so if the user or ARM team actually unlabels `ARMChangesRequested`, then we're actually ok + // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels + if (containsAll(existingLabels, ["ARMSignedOff"])) { + if (existingLabels.includes("ARMChangesRequested")) { + context.toAdd.add("ARMChangesRequested"); + } + if (existingLabels.includes("WaitForARMFeedback")) { + context.toRemove.add("WaitForARMFeedback"); + } + } + // if there are ARM changes requested, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed + else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { + if (existingLabels.includes("WaitForARMFeedback")) { + context.toRemove.add("WaitForARMFeedback"); + } + } + // finally, if ARMChangesRequested are not present, and we've gotten here by lac;k of signoff, we should add the "WaitForARMFeedback" label + else if (containsNone(existingLabels, ["ARMChangesRequested"])) { + if (!existingLabels.includes("WaitForARMFeedback")) { + context.toAdd.add("WaitForARMFeedback"); + } + } +} + +/** + * + * @param {any[]} arr + * @param {any[]} values + * @returns + */ +function containsAll(arr, values) { + return values.every((value) => arr.includes(value)); +} + +/** + * + * @param {any[]} arr + * @param {any[]} values + * @returns + */ +function containsNone(arr, values) { + return values.every((value) => !arr.includes(value)); +} + +/** +This function determines which labels of the ARM review should +be applied to given PR. It adds and removes the labels as appropriate. It used to be called +ProcessARMReview, but was renamed to processImpactAssessment to better reflect its purpose given that is +merely evaluating the impact assessment of the PR and returning a final set of labels to be applied/removed +from the PR. + +This function does the following, **among other things**: + +- Adds the "ARMReview" label if all of the following conditions hold: + - The processed PR "isReleaseBranch" or "isShiftLeftPRWithRPSaaSDev" + - The PR is not a draft, as determined by "isDraftPR" + - The PR is labelled with "resource-manager" label, meaning it pertains + to ARM, as previously determined by the "isManagementPR" function, + called from the "getPRType" function. + +- Calls the "processARMReviewWorkflowLabels" function if "ARMReview" label applies. +*/ +// todo: refactor to take context: PRContext as input instead of IValidatorContext. +// All downstream usage appears to be using "context.contextConfig() as PRContext". +/** + * @param {string} targetBranch + * @param {LabelContext} labelContext + * @param {boolean} resourceManagerLabelShouldBePresent + * @param {boolean} versioningReviewRequiredLabelShouldBePresent + * @param {boolean} breakingChangeReviewRequiredLabelShouldBePresent + * @param {boolean} ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent + * @param {boolean} rpaasExceptionLabelShouldBePresent + * @param {boolean} ciRpaasRPNotInPrivateRepoLabelShouldBePresent + * @param {boolean} isNewApiVersion + * @param {boolean} isDraft + * @returns {Promise<{armReviewLabelShouldBePresent: boolean}>} + */ +export async function processImpactAssessment( + targetBranch, + labelContext, + resourceManagerLabelShouldBePresent, + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent, + isNewApiVersion, + isDraft, +) { console.log("ENTER definition processARMReview") - const [owner, repo, prNumber] = getPRInfo(context) const armReviewLabel = new Label("ARMReview", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. @@ -403,20 +509,21 @@ async function processARMReview( // By default this label should not be present. We may determine later in this function that it should be present after all. newApiVersionLabel.shouldBePresent = false; - const branch = (context.contextConfig() as PRContext).targetBranch - const prTitle = await getPrTitle(owner, repo, prNumber) - const isReleaseBranchVal: boolean = isReleaseBranch(branch) - const isShiftLeftPRWithRPSaaSDevVal: boolean = isShiftLeftPRWithRPSaaSDev(prTitle, branch) - const isBranchInScopeOfSpecReview: boolean = isReleaseBranchVal || isShiftLeftPRWithRPSaaSDevVal - let isNewApiVersionVal: boolean | "not_computed" = "not_computed" - let isMissingBaseCommit: boolean | "not_computed" = "not_computed" + const branch = targetBranch; + const isReleaseBranchVal = isReleaseBranch(branch) + + // we used to also calculate if the branch name was from ShiftLeft, in which case we would or that + // with isReleaseBranchVal to see if it's in scope of ARM review. ShiftLeft is not supported anymore, + // so we only check if the branch is a release branch. + // const prTitle = await getPrTitle(owner, repo, prNumber) + // const isShiftLeftPRWithRPSaaSDevVal = isShiftLeftPRWithRPSaaSDev(prTitle, branch) + const isBranchInScopeOfSpecReview = isReleaseBranchVal// || isShiftLeftPRWithRPSaaSDevVal // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic // determines which kind of review exactly we need. let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview if (specReviewApplies) { - isNewApiVersionVal = await isNewApiVersion(context) - if (isNewApiVersionVal) { + if (isNewApiVersion) { // Note that in case of data-plane PRs, the addition of this label will result // in API stewardship board review being required. // See requiredLabelsRules.ts. @@ -438,12 +545,9 @@ async function processARMReview( armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) console.log(`RETURN definition processARMReview. ` - + `url: ${new PRKey(owner, repo, prNumber).toUrl()}, owner: ${owner}, repo: ${repo}, pr: ${prNumber}, branch: ${branch}, ` + `isReleaseBranch: ${isReleaseBranchVal}, ` - + `isShiftLeftPRWithRPSaaSDev: ${isShiftLeftPRWithRPSaaSDevVal}, ` + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` - + `isNewApiVersion: ${isNewApiVersionVal}, ` - + `isMissingBaseCommit: ${isMissingBaseCommit}, ` + + `isNewApiVersion: ${isNewApiVersion}, ` + `isDraft: ${isDraft}, ` + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) @@ -451,11 +555,201 @@ async function processARMReview( return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } } -function isReleaseBranch(branchName: string) { +/** + * @param {string} branchName + * @returns {boolean} + */ +function isReleaseBranch(branchName) { const branchRegex = [/main/, /RPSaaSMaster/, /release*/, /ARMCoreRPDev/]; return branchRegex.some((b) => b.test(branchName)); } +/** +CODESYNC: +- requiredLabelsRules.ts / requiredLabelsRules +- https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml + +This function determines which label from the ARM review workflow labels +should be present on the PR. It adds and removes the labels as appropriate. + +In other words, this function captures the +ARM review workflow label processing logic. + +To be exact, this function executes if and only if the PR in question +has been determined to have the "ARMReview" label, denoting given PR +is in scope for ARM review. + +The implementation of this function is the source of truth specifying the +desired behavior. + +To understand this implementation, the most important constraint to keep in mind +is that if "ARMReview" label is present, then exactly one of the following +labels must be present: + +- NotReadyForARMReview +- WaitForARMFeedback +- ARMChangesRequested +- ARMSignedOff + +Note that another important place in this codebase where ARM review workflow +labels are being removed or added to a PR is pipelineBotOnPRLabelEvent.ts. +*/ +/** + * @param {LabelContext} labelContext + * @param {boolean} armReviewLabelShouldBePresent + * @param {boolean} versioningReviewRequiredLabelShouldBePresent + * @param {boolean} breakingChangeReviewRequiredLabelShouldBePresent + * @param {boolean} ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent + * @param {boolean} rpaasExceptionLabelShouldBePresent + * @param {boolean} ciRpaasRPNotInPrivateRepoLabelShouldBePresent + * @returns {Promise} + */ +async function processARMReviewWorkflowLabels( + labelContext, + armReviewLabelShouldBePresent, + versioningReviewRequiredLabelShouldBePresent, + breakingChangeReviewRequiredLabelShouldBePresent, + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent +) { + console.log("ENTER definition processARMReviewWorkflowLabels"); + + const notReadyForArmReviewLabel = new Label( + "NotReadyForARMReview", + labelContext.present); + + const waitForArmFeedbackLabel = new Label( + "WaitForARMFeedback", + labelContext.present + ); + + const armChangesRequestedLabel = new Label( + "ARMChangesRequested", + labelContext.present + ); + + const armSignedOffLabel = new Label("ARMSignedOff", labelContext.present); + + const blockedOnVersioningPolicy = getBlockedOnVersioningPolicy( + labelContext, + breakingChangeReviewRequiredLabelShouldBePresent, + versioningReviewRequiredLabelShouldBePresent + ); + + const blockedOnRpaas = getBlockedOnRpaas( + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent + ); + + const blocked = blockedOnVersioningPolicy || blockedOnRpaas; + + // If given PR is in scope of ARM review and it is blocked for any reason, + // the "NotReadyForARMReview" label should be present, to the exclusion + // of all other ARM review workflow labels. + notReadyForArmReviewLabel.shouldBePresent = + armReviewLabelShouldBePresent && blocked; + + // If given PR is in scope of ARM review and the review is not blocked, + // then "ARMSignedOff" label should remain present on the PR if it was + // already present. This means that labels "ARMChangesRequested" + // and "WaitForARMFeedback" are invalid and will be removed by automation + // in presence of "ARMSignedOff". + armSignedOffLabel.shouldBePresent = + armReviewLabelShouldBePresent && + !blocked && + armSignedOffLabel.present; + + // If given PR is in scope of ARM review and the review is not blocked and + // not signed-off, then the label "ARMChangesRequested" should remain present + // if it was already present. This means that labels "WaitForARMFeedback" + // is invalid and will be removed by automation in presence of + // "WaitForARMFeedback". + armChangesRequestedLabel.shouldBePresent = + armReviewLabelShouldBePresent && + !blocked && + !armSignedOffLabel.shouldBePresent && + armChangesRequestedLabel.present; + + // If given PR is in scope of ARM review and the review is not blocked and + // not signed-off, and ARM reviewer didn't request any changes, + // then the label "WaitForARMFeedback" should be present on the PR, whether + // it was present before or not. + waitForArmFeedbackLabel.shouldBePresent = + armReviewLabelShouldBePresent && + !blocked && + !armSignedOffLabel.shouldBePresent && + !armChangesRequestedLabel.shouldBePresent && + (waitForArmFeedbackLabel.present || true); + + const exactlyOneArmReviewWorkflowLabelShouldBePresent = + (Number(notReadyForArmReviewLabel.shouldBePresent) + + Number(armSignedOffLabel.shouldBePresent) + + Number(armChangesRequestedLabel.shouldBePresent) + + Number(waitForArmFeedbackLabel.shouldBePresent) === + 1) || !armReviewLabelShouldBePresent + + if (!exactlyOneArmReviewWorkflowLabelShouldBePresent) { + console.warn( + "ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false" + ); + } + + notReadyForArmReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + armSignedOffLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + armChangesRequestedLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + waitForArmFeedbackLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + + console.log( + `RETURN definition processARMReviewWorkflowLabels. ` + + `presentLabels: ${[...labelContext.present].join(",")}, ` + + `blockedOnVersioningPolicy: ${blockedOnVersioningPolicy}. ` + + `blockedOnRpaas: ${blockedOnRpaas}. ` + + `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. ` + ); + return; +} + +/** + * @param {LabelContext} labelContext + * @param {boolean} breakingChangeReviewRequiredLabelShouldBePresent + * @param {boolean} versioningReviewRequiredLabelShouldBePresent + * @returns {boolean} + */ +function getBlockedOnVersioningPolicy( + labelContext, + breakingChangeReviewRequiredLabelShouldBePresent, + versioningReviewRequiredLabelShouldBePresent +) { + const pendingVersioningReview = + versioningReviewRequiredLabelShouldBePresent && + !anyApprovalLabelPresent("SameVersion", [...labelContext.present]); + + const pendingBreakingChangeReview = + breakingChangeReviewRequiredLabelShouldBePresent && + !anyApprovalLabelPresent("CrossVersion", [...labelContext.present]); + + const blockedOnVersioningPolicy = + pendingVersioningReview || pendingBreakingChangeReview + return blockedOnVersioningPolicy; +} + +/** + * @param {boolean} ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent + * @param {boolean} rpaasExceptionLabelShouldBePresent + * @param {boolean} ciRpaasRPNotInPrivateRepoLabelShouldBePresent + * @returns {boolean} + */ +function getBlockedOnRpaas( + ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, + rpaasExceptionLabelShouldBePresent, + ciRpaasRPNotInPrivateRepoLabelShouldBePresent +) +{ + return (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) + || ciRpaasRPNotInPrivateRepoLabelShouldBePresent +} // #endregion // #region LabelRules diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index e01cf8987768..b68b6123f8f1 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -23,7 +23,7 @@ import { extractInputs } from "../context.js"; // eslint-disable-next-line no-unused-vars import { commentOrUpdate } from "../comment.js"; import { PER_PAGE_MAX } from "../github.js"; -import { verRevApproval, brChRevApproval, getViolatedRequiredLabelsRules } from "./labelling.js"; +import { verRevApproval, brChRevApproval, getViolatedRequiredLabelsRules, processArmReviewLabels, processImpactAssessment } from "./labelling.js"; import { brchTsg, @@ -34,7 +34,6 @@ import { typeSpecRequirementArmTsg, typeSpecRequirementDataPlaneTsg, } from "./tsgs.js"; -import { toASCII } from "punycode"; /** * @typedef {Object} CheckMetadata @@ -351,13 +350,9 @@ export async function summarizeChecksImpl( /** @type {string[]} */ let labelNames = labels.map((/** @type {{ name: string; }} */ label) => label.name); - // /** @type { string | undefined } */ - // const changedLabel = context.payload.label?.name; - const isDraft = context.payload.pull_request?.draft ?? false; - // todo: how important is it that we know if we're in draft? I don't want to have to pull the PR details unless we actually need to // do we need to pull this from the PR? if triggered from a workflow_run we won't have payload.pullrequest populated - let labelContext = await updateLabels(targetBranch, isDraft, labelNames); + let labelContext = await updateLabels(labelNames, {}); for (const label of labelContext.toRemove) { core.info(`Removing label: ${label} from ${owner}/${repo}#${issue_number}.`); @@ -399,6 +394,7 @@ export async function summarizeChecksImpl( issue_number, EXCLUDED_CHECK_NAMES, ); + console.log(workflowRuns); const commentBody = await createNextStepsComment( core, @@ -519,27 +515,6 @@ function getGraphQLQuery(owner, repo, sha, prNumber) { // #endregion // #region label update - -/** - * - * @param {any[]} arr - * @param {any[]} values - * @returns - */ -function containsAll(arr, values) { - return values.every((value) => arr.includes(value)); -} - -/** - * - * @param {any[]} arr - * @param {any[]} values - * @returns - */ -function containsNone(arr, values) { - return values.every((value) => !arr.includes(value)); -} - /** * @param {Set} labelsToAdd * @param {Set} labelsToRemove @@ -554,20 +529,16 @@ function warnIfLabelSetsIntersect(labelsToAdd, labelsToRemove) { } } - // * @param {string} eventName // * @param {string | undefined } changedLabel /** - * @param {string} targetBranch - * @param {boolean} isDraft * @param {string[]} existingLabels + * @param {Object} assessment * @returns {import("./labelling.js").LabelContext} */ export function updateLabels( - // eventName, - targetBranch, - isDraft, existingLabels, + assessment // changedLabel ) { // logic for this function originally present in: @@ -581,405 +552,62 @@ export function updateLabels( toAdd: new Set(), toRemove: new Set() } - console.log(targetBranch); - console.log(isDraft); + + console.log(`log ${assessment} to eliminate warning. Not actually populated yet. `); + + /** @type {import("./labelling.js").ImpactAssessment} */ + const impactAssessment = { + suppressionReviewRequired: false, + versioningReviewRequired: false, + breakingChangeReviewRequired: false, + rpaasChange: false, + newRP: false, + rpaasRPMissing: false, + rpaasRpNotInPrivateRepo: false, + resourceManagerRequired: false, + rpaasExceptionRequired: false, + typeSpecChanged: false, + isNewApiVersion: false, + isDraft: false, + labelContext: { + present: new Set(), + toAdd: new Set(), + toRemove: new Set() + }, + targetBranch: "main" + }; + + console.log(`Downloaded impact assessment: ${JSON.stringify(impactAssessment)}`); + // Merge impact assessment labels into the main labelContext + impactAssessment.labelContext.toAdd.forEach(label => { + labelContext.toAdd.add(label); + }); + impactAssessment.labelContext.toRemove.forEach(label => { + labelContext.toRemove.add(label); + }); // this is the only labelling that was part of original pipelinebot logic processArmReviewLabels(labelContext, existingLabels); - // // this one SHOULD remain here. It has a lot of the logic around handling ARM review labels - // const { armReviewLabelShouldBePresent } = await processARMReview( - // context, - // labelContext, - // resourceManagerLabelShouldBePresent, - // versioningReviewRequiredLabelShouldBePresent, - // breakingChangeReviewRequiredLabelShouldBePresent, - // ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, - // rpaasExceptionLabelShouldBePresent, - // ciRpaasRPNotInPrivateRepoLabelShouldBePresent, - // isDraft, - // ); + // will further update the label context if necessary + processImpactAssessment( + impactAssessment.targetBranch, + labelContext, + impactAssessment.resourceManagerRequired, + impactAssessment.versioningReviewRequired, + impactAssessment.breakingChangeReviewRequired, + impactAssessment.rpaasRPMissing, + impactAssessment.rpaasExceptionRequired, + impactAssessment.rpaasRpNotInPrivateRepo, + impactAssessment.isNewApiVersion, + impactAssessment.isDraft + ); warnIfLabelSetsIntersect(labelContext.toAdd, labelContext.toRemove) return labelContext; } -/** - * @param {import("./labelling.js").LabelContext} context - * @param {string[]} existingLabels - * @returns {void} - */ -export function processArmReviewLabels( - context, - existingLabels -) { - // the important part about how this will work depends how the users use it - // EG: if they add the "ARMSignedOff" label, we will remove the "ARMChangesRequested" and "WaitForARMFeedback" labels. - // if they add the "ARMChangesRequested" label, we will remove the "WaitForARMFeedback" label. - // if they remove the "ARMChangesRequested" label, we will add the "WaitForARMFeedback" label. - // so if the user or ARM team actually unlabels `ARMChangesRequested`, then we're actually ok - // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels - if (containsAll(existingLabels, ["ARMSignedOff"])) { - if (existingLabels.includes("ARMChangesRequested")) { - context.toAdd.add("ARMChangesRequested"); - } - if (existingLabels.includes("WaitForARMFeedback")) { - context.toRemove.add("WaitForARMFeedback"); - } - } - // if there are ARM changes requested, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed - else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { - if (existingLabels.includes("WaitForARMFeedback")) { - context.toRemove.add("WaitForARMFeedback"); - } - } - // finally, if ARMChangesRequested are not present, and we've gotten here by lac;k of signoff, we should add the "WaitForARMFeedback" label - else if (containsNone(existingLabels, ["ARMChangesRequested"])) { - if (!existingLabels.includes("WaitForARMFeedback")) { - context.toAdd.add("WaitForARMFeedback"); - } - } -} -// /** -// This function determines which labels of the ARM review should -// be applied to given PR. It adds and removes the labels as appropriate. - -// This function does the following, **among other things**: - -// - Adds the "ARMReview" label if all of the following conditions hold: -// - The processed PR "isReleaseBranch" or "isShiftLeftPRWithRPSaaSDev" -// - The PR is not a draft, as determined by "isDraftPR" -// - The PR is labelled with "resource-manager" label, meaning it pertains -// to ARM, as previously determined by the "isManagementPR" function, -// called from the "getPRType" function. - -// - Calls the "processARMReviewWorkflowLabels" function if "ARMReview" label applies. -// */ -// // todo: refactor to take context: PRContext as input instead of IValidatorContext. -// // All downstream usage appears to be using "context.contextConfig() as PRContext". -// async function processARMReview( -// context: IValidatorContext, -// owner: string, -// repo: string, -// issue_number: number, -// labelContext: LabelContext, -// resourceManagerLabelShouldBePresent: boolean, -// versioningReviewRequiredLabelShouldBePresent: boolean, -// breakingChangeReviewRequiredLabelShouldBePresent: boolean, -// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, -// rpaasExceptionLabelShouldBePresent: boolean, -// ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean, -// isDraft: boolean, -// ): Promise<{ armReviewLabelShouldBePresent: boolean }> { -// console.log("ENTER definition processARMReview") - -// const armReviewLabel = new Label("ARMReview", labelContext.present); -// // By default this label should not be present. We may determine later in this function that it should be present after all. -// armReviewLabel.shouldBePresent = false; - -// const newApiVersionLabel = new Label("new-api-version", labelContext.present); -// // By default this label should not be present. We may determine later in this function that it should be present after all. -// newApiVersionLabel.shouldBePresent = false; - -// const branch = (context.contextConfig() as PRContext).targetBranch -// const prTitle = await getPrTitle(owner, repo, prNumber) -// const isReleaseBranchVal: boolean = isReleaseBranch(branch) -// const isShiftLeftPRWithRPSaaSDevVal: boolean = isShiftLeftPRWithRPSaaSDev(prTitle, branch) -// const isBranchInScopeOfSpecReview: boolean = isReleaseBranchVal || isShiftLeftPRWithRPSaaSDevVal -// let isNewApiVersionVal: boolean | "not_computed" = "not_computed" -// let isMissingBaseCommit: boolean | "not_computed" = "not_computed" - -// // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic -// // determines which kind of review exactly we need. -// let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview -// if (specReviewApplies) { -// isNewApiVersionVal = await isNewApiVersion(context) -// if (isNewApiVersionVal) { -// // Note that in case of data-plane PRs, the addition of this label will result -// // in API stewardship board review being required. -// // See requiredLabelsRules.ts. -// newApiVersionLabel.shouldBePresent = true; -// } - -// armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent -// await processARMReviewWorkflowLabels( -// labelContext, -// armReviewLabel.shouldBePresent, -// versioningReviewRequiredLabelShouldBePresent, -// breakingChangeReviewRequiredLabelShouldBePresent, -// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, -// rpaasExceptionLabelShouldBePresent, -// ciRpaasRPNotInPrivateRepoLabelShouldBePresent) -// } - -// newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) -// armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) - -// console.log(`RETURN definition processARMReview. ` -// + `url: ${new PRKey(owner, repo, prNumber).toUrl()}, owner: ${owner}, repo: ${repo}, pr: ${prNumber}, branch: ${branch}, ` -// + `isReleaseBranch: ${isReleaseBranchVal}, ` -// + `isShiftLeftPRWithRPSaaSDev: ${isShiftLeftPRWithRPSaaSDevVal}, ` -// + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` -// + `isNewApiVersion: ${isNewApiVersionVal}, ` -// + `isMissingBaseCommit: ${isMissingBaseCommit}, ` -// + `isDraft: ${isDraft}, ` -// + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` -// + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) - -// return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } -// } - -// function isReleaseBranch(branchName: string) { -// const branchRegex = [/main/, /RPSaaSMaster/, /release*/, /ARMCoreRPDev/]; -// return branchRegex.some((b) => b.test(branchName)); -// } - -// // For shift-left review process, if the PR target branch is RPSaaSDev, it still need ARM review -// function isShiftLeftPRWithRPSaaSDev( -// prTitle: string, -// branch: string -// ): boolean { -// const shiftLeftPRTitle = "[AutoSync]"; -// const isShiftLeftPR = prTitle.includes(shiftLeftPRTitle) && branch === "RPSaaSDev"; -// if (isShiftLeftPR) { -// console.log( -// `The PR is shift-left PR with RPSaaSDev branch. it need ARM review` -// ); -// } -// return isShiftLeftPR; -// } - -// async function isNewApiVersion(context: IValidatorContext): Promise { -// const pr = await createPullRequestProperties( -// context, -// "pr-summary-new-api-version" -// ); -// const handlers: ChangeHandler[] = []; -// let isAddingNewApiVersion = false; -// const apiVersionSet = new Set(); - -// const rpFolders = new Set(); - -// const createSwaggerFileHandler = () => { -// return (e: PRChange) => { -// if (e.changeType === "Addition") { -// const apiVersion = getApiVersionFromSwaggerFile(e.filePath); -// if (apiVersion) { -// apiVersionSet.add(apiVersion); -// } -// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); -// if (rpFolder !== undefined) { -// rpFolders.add(rpFolder); -// } -// console.log(`apiVersion: ${apiVersion}, rpFolder: ${rpFolder}`); -// } else if (e.changeType === "Update") { -// const rpFolder = getRPFolderFromSwaggerFile(e.filePath); -// if (rpFolder !== undefined) { -// rpFolders.add(rpFolder); -// } -// } -// }; -// }; - -// handlers.push({ SwaggerFile: createSwaggerFileHandler() }); -// await processPrChanges(context, handlers); - -// console.log(`rpFolders: ${Array.from(rpFolders).join(",")}`); - -// const firstRPFolder = Array.from(rpFolders)[0]; - -// console.log(`apiVersion: ${Array.from(apiVersionSet).join(",")}`); - -// if (firstRPFolder === undefined) { -// console.log("RP folder not found."); -// return false; -// } - -// const targetBranchRPFolder = resolve(pr?.workingDir!, firstRPFolder); - -// console.log(`targetBranchRPFolder: ${targetBranchRPFolder}`); - -// const existingApiVersions = -// getAllApiVersionFromRPFolder(targetBranchRPFolder); - -// console.log(`existingApiVersions: ${existingApiVersions.join(",")}`); - -// for (const apiVersion of apiVersionSet) { -// if (!existingApiVersions.includes(apiVersion)) { -// console.log( -// `The apiVersion ${apiVersion} is added. and not found in existing ApiVersions` -// ); -// isAddingNewApiVersion = true; -// } -// } -// return isAddingNewApiVersion; -// } - -// /** -// CODESYNC: -// - requiredLabelsRules.ts / requiredLabelsRules -// - https://github.com/Azure/azure-rest-api-specs/blob/main/.github/comment.yml - -// This function determines which label from the ARM review workflow labels -// should be present on the PR. It adds and removes the labels as appropriate. - -// In other words, this function captures the -// ARM review workflow label processing logic. - -// To be exact, this function executes if and only if the PR in question -// has been determined to have the "ARMReview" label, denoting given PR -// is in scope for ARM review. - -// The implementation of this function is the source of truth specifying the -// desired behavior. - -// To understand this implementation, the most important constraint to keep in mind -// is that if "ARMReview" label is present, then exactly one of the following -// labels must be present: - -// - NotReadyForARMReview -// - WaitForARMFeedback -// - ARMChangesRequested -// - ARMSignedOff - -// Note that another important place in this codebase where ARM review workflow -// labels are being removed or added to a PR is pipelineBotOnPRLabelEvent.ts. -// */ -// async function processARMReviewWorkflowLabels( -// labelContext: LabelContext, -// armReviewLabelShouldBePresent: boolean, -// versioningReviewRequiredLabelShouldBePresent: boolean, -// breakingChangeReviewRequiredLabelShouldBePresent: boolean, -// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, -// rpaasExceptionLabelShouldBePresent: boolean, -// ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean -// ): Promise { -// console.log("ENTER definition processARMReviewWorkflowLabels"); - -// const notReadyForArmReviewLabel = new Label( -// "NotReadyForARMReview", -// labelContext.present); - -// const waitForArmFeedbackLabel = new Label( -// "WaitForARMFeedback", -// labelContext.present -// ); - -// const armChangesRequestedLabel = new Label( -// "ARMChangesRequested", -// labelContext.present -// ); - -// const armSignedOffLabel = new Label("ARMSignedOff", labelContext.present); - -// const blockedOnVersioningPolicy = getBlockedOnVersioningPolicy( -// labelContext, -// breakingChangeReviewRequiredLabelShouldBePresent, -// versioningReviewRequiredLabelShouldBePresent -// ); - -// const blockedOnRpaas = getBlockedOnRpaas( -// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, -// rpaasExceptionLabelShouldBePresent, -// ciRpaasRPNotInPrivateRepoLabelShouldBePresent -// ); - -// const blocked = blockedOnVersioningPolicy || blockedOnRpaas; - -// // If given PR is in scope of ARM review and it is blocked for any reason, -// // the "NotReadyForARMReview" label should be present, to the exclusion -// // of all other ARM review workflow labels. -// notReadyForArmReviewLabel.shouldBePresent = -// armReviewLabelShouldBePresent && blocked; - -// // If given PR is in scope of ARM review and the review is not blocked, -// // then "ARMSignedOff" label should remain present on the PR if it was -// // already present. This means that labels "ARMChangesRequested" -// // and "WaitForARMFeedback" are invalid and will be removed by automation -// // in presence of "ARMSignedOff". -// armSignedOffLabel.shouldBePresent = -// armReviewLabelShouldBePresent && -// !blocked && -// armSignedOffLabel.present; - -// // If given PR is in scope of ARM review and the review is not blocked and -// // not signed-off, then the label "ARMChangesRequested" should remain present -// // if it was already present. This means that labels "WaitForARMFeedback" -// // is invalid and will be removed by automation in presence of -// // "WaitForARMFeedback". -// armChangesRequestedLabel.shouldBePresent = -// armReviewLabelShouldBePresent && -// !blocked && -// !armSignedOffLabel.shouldBePresent && -// armChangesRequestedLabel.present; - -// // If given PR is in scope of ARM review and the review is not blocked and -// // not signed-off, and ARM reviewer didn't request any changes, -// // then the label "WaitForARMFeedback" should be present on the PR, whether -// // it was present before or not. -// waitForArmFeedbackLabel.shouldBePresent = -// armReviewLabelShouldBePresent && -// !blocked && -// !armSignedOffLabel.shouldBePresent && -// !armChangesRequestedLabel.shouldBePresent && -// (waitForArmFeedbackLabel.present || true); - -// const exactlyOneArmReviewWorkflowLabelShouldBePresent = -// (Number(notReadyForArmReviewLabel.shouldBePresent) + -// Number(armSignedOffLabel.shouldBePresent) + -// Number(armChangesRequestedLabel.shouldBePresent) + -// Number(waitForArmFeedbackLabel.shouldBePresent) === -// 1) || !armReviewLabelShouldBePresent - -// if (!exactlyOneArmReviewWorkflowLabelShouldBePresent) { -// console.warn( -// "ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false" -// ); -// } - -// notReadyForArmReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); -// armSignedOffLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); -// armChangesRequestedLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); -// waitForArmFeedbackLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - -// console.log( -// `RETURN definition processARMReviewWorkflowLabels. ` + -// `presentLabels: ${[...labelContext.present].join(",")}, ` + -// `blockedOnVersioningPolicy: ${blockedOnVersioningPolicy}. ` + -// `blockedOnRpaas: ${blockedOnRpaas}. ` + -// `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. ` -// ); -// return; -// } - -// function getBlockedOnVersioningPolicy( -// labelContext: LabelContext, -// breakingChangeReviewRequiredLabelShouldBePresent: boolean, -// versioningReviewRequiredLabelShouldBePresent: boolean -// ) { -// const pendingVersioningReview = -// versioningReviewRequiredLabelShouldBePresent && -// !anyApprovalLabelPresent("SameVersion", [...labelContext.present]); - -// const pendingBreakingChangeReview = -// breakingChangeReviewRequiredLabelShouldBePresent && -// !anyApprovalLabelPresent("CrossVersion", [...labelContext.present]); - -// const blockedOnVersioningPolicy = -// pendingVersioningReview || pendingBreakingChangeReview -// return blockedOnVersioningPolicy; -// } - -// function getBlockedOnRpaas( -// ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent: boolean, -// rpaasExceptionLabelShouldBePresent: boolean, -// ciRpaasRPNotInPrivateRepoLabelShouldBePresent: boolean -// ) -// { -// return (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) -// || ciRpaasRPNotInPrivateRepoLabelShouldBePresent -// } // #endregion // #region checks /** diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index b3a5687e2b98..09965e736916 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -149,7 +149,6 @@ export async function evaluateImpact( const newApiVersion = await isNewApiVersion(context); return { - prType: [], suppressionReviewRequired: labelContext.toAdd.has("suppressionsReviewRequired"), versioningReviewRequired: versioningReviewRequiredLabelShouldBePresent, breakingChangeReviewRequired: breakingChangeReviewRequiredLabelShouldBePresent, @@ -163,6 +162,7 @@ export async function evaluateImpact( isNewApiVersion: newApiVersion, isDraft: context.isDraft, labelContext: labelContext, + targetBranch: context.targetBranch }; } diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index 5a3fc3cf21c2..e6ad0120e84e 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -187,7 +187,6 @@ export type LabelContext = { }; export type ImpactAssessment = { - prType: string[]; resourceManagerRequired: boolean; suppressionReviewRequired: boolean; versioningReviewRequired: boolean; @@ -201,6 +200,7 @@ export type ImpactAssessment = { typeSpecChanged: boolean; isDraft: boolean; labelContext: LabelContext; + targetBranch: string; }; export class Label { From 59b65287094bf3c912d3e5e790fcef70739b6273 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 21:22:18 +0000 Subject: [PATCH 39/67] fallback for issue number --- .github/workflows/src/context.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/src/context.js b/.github/workflows/src/context.js index 99e78b52eb56..a9848e1c1b88 100644 --- a/.github/workflows/src/context.js +++ b/.github/workflows/src/context.js @@ -219,6 +219,15 @@ export async function extractInputs(github, context, core) { core.info( `Could not find 'issue-number' artifact, which is required to associate the triggering workflow run with a PR`, ); + try { + // Fallback: search for PR number by commit SHA + core.info(`Falling back to REST API to find PR for commit ${payload.workflow_run.head_sha}`); + const fallback = await getIssueNumber({ head_sha: payload.workflow_run.head_sha, github, core }); + issue_number = fallback.issueNumber; + } + catch (error) { + core.error(`Error: ${error}`); + } } } else { throw new Error( From ebf47035db69415678aeaf48aa72ec477bec4bb3 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 21:26:34 +0000 Subject: [PATCH 40/67] bump how we calculate --- eng/tools/summarize-impact/src/impact.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index 09965e736916..51ea4bcf10bc 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -148,6 +148,8 @@ export async function evaluateImpact( const newApiVersion = await isNewApiVersion(context); + console.log(`Here is the calculated labelContext. ${JSON.stringify(labelContext, null, 2)}`); + return { suppressionReviewRequired: labelContext.toAdd.has("suppressionsReviewRequired"), versioningReviewRequired: versioningReviewRequiredLabelShouldBePresent, From 354d502783348a165f6362fca65a7360c1c741dc Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 21:36:54 +0000 Subject: [PATCH 41/67] fix the issue with outputs --- eng/tools/summarize-impact/src/cli.ts | 13 +++++++++++-- eng/tools/summarize-impact/src/impact.ts | 2 -- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 571a095277c2..bf482c49a325 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -120,10 +120,19 @@ export async function main() { let impact = await evaluateImpact(prContext, labelContext); - console.log("Evaluated impact: ", JSON.stringify(impact)); + // sets by default are not serializable, so we need to convert them to arrays + // before we can write them to the output file. + function setReplacer(_key: string, value: any) { + if (value instanceof Set) { + return [...value]; + } + return value; + } + + console.log("Evaluated impact: ", JSON.stringify(impact, setReplacer, 2)); // Write to a temp file that can get picked up later. const summaryFile = join(process.cwd(), "summary.json"); - fs.writeFileSync(summaryFile, JSON.stringify(impact, null, 2)); + fs.writeFileSync(summaryFile, JSON.stringify(impact, setReplacer, 2)); setOutput("summary", summaryFile); } diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index 51ea4bcf10bc..09965e736916 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -148,8 +148,6 @@ export async function evaluateImpact( const newApiVersion = await isNewApiVersion(context); - console.log(`Here is the calculated labelContext. ${JSON.stringify(labelContext, null, 2)}`); - return { suppressionReviewRequired: labelContext.toAdd.has("suppressionsReviewRequired"), versioningReviewRequired: versioningReviewRequiredLabelShouldBePresent, From 99d44b5d979edc4cfbfbc685da28a1cceab27a7f Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 22:41:55 +0000 Subject: [PATCH 42/67] properly process workflows now --- .../src/summarize-checks/summarize-checks.js | 129 +++--------------- 1 file changed, 21 insertions(+), 108 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index b68b6123f8f1..f8ae0977aa3f 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -83,7 +83,6 @@ import { /** * @typedef {Object} GraphQLCheckSuite * @property {GraphQLCheckRun[]} nodes - * @property {WorkflowRunInfo} workflowRun */ /** @@ -104,10 +103,6 @@ import { /** * @typedef {Object} GraphQLResponse * @property {GraphQLResource} resource - * @property {Object} repository - * @property {Object} repository.object - * @property {Object} repository.object.associatedPullRequests - * @property {Array<{number: number, commits: {nodes: Array<{commit: GraphQLCommit}>}}>} repository.object.associatedPullRequests.nodes * @property {Object} rateLimit * @property {number} rateLimit.limit * @property {number} rateLimit.cost @@ -318,15 +313,8 @@ export async function summarizeChecksImpl( `Handling ${event_name} event for PR #${issue_number} in ${owner}/${repo} with targeted branch ${targetBranch}`, ); - // we always want fresh labels - // we always need a file list. We are pulling the file list from gh because we won't be in the correct file context - // to await getChangedFiles, as we run on pull_request_target and workflow_run events. We _aren't_ in the actual PR context - // as far as files go - - // If there is a check that DOES need PR context, we will need to shift that logic onto a solo workflow that runs on pull_request - // events, and then we can await getChangedFiles. - const [labels, files] = await Promise.all([ - github.paginate( + // retrieve latest labels state + const labels = await github.paginate( github.rest.issues.listLabelsOnIssue, { owner, @@ -334,18 +322,18 @@ export async function summarizeChecksImpl( issue_number: issue_number, per_page: PER_PAGE_MAX } - ), - github.paginate( - github.rest.pulls.listFiles, - { - owner, - repo, - pull_number: issue_number - } - ) - ]); + ); - core.info(JSON.stringify(files)); + /** @type {[CheckRunData[], CheckRunData[]]} */ + const [requiredCheckRuns, fyiCheckRuns] = await getCheckRunTuple( + github, + core, + owner, + repo, + head_sha, + issue_number, + EXCLUDED_CHECK_NAMES, + ); /** @type {string[]} */ let labelNames = labels.map((/** @type {{ name: string; }} */ label) => label.name); @@ -384,17 +372,6 @@ export async function summarizeChecksImpl( } } - /** @type {[CheckRunData[], CheckRunData[], WorkflowRunInfo[]]} */ - const [requiredCheckRuns, fyiCheckRuns, workflowRuns] = await getCheckRunTuple( - github, - core, - owner, - repo, - head_sha, - issue_number, - EXCLUDED_CHECK_NAMES, - ); - console.log(workflowRuns); const commentBody = await createNextStepsComment( core, @@ -470,38 +447,6 @@ function getGraphQLQuery(owner, repo, sha, prNumber) { } } } - repository(owner: "${owner}", name: "${repo}") { - object(expression: "${sha}") { - ... on Commit { - associatedPullRequests(first: 10) { - nodes { - number - commits(last: 1) { - nodes { - commit { - checkSuites(first: 20) { - nodes { - workflowRun { - id - databaseId - url - workflowId - name - status - conclusion - createdAt - updatedAt - } - } - } - } - } - } - } - } - } - } - } rateLimit { limit cost @@ -539,7 +484,6 @@ function warnIfLabelSetsIntersect(labelsToAdd, labelsToRemove) { export function updateLabels( existingLabels, assessment - // changedLabel ) { // logic for this function originally present in: // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts @@ -618,7 +562,7 @@ export function updateLabels( * @param {string} head_sha - The commit SHA to check. * @param {number} prNumber - The pull request number. * @param {string[]} excludedCheckNames - * @returns {Promise<[CheckRunData[], CheckRunData[], WorkflowRunInfo[]]>} + * @returns {Promise<[CheckRunData[], CheckRunData[]]>} */ export async function getCheckRunTuple( github, @@ -639,14 +583,11 @@ export async function getCheckRunTuple( const response = await github.graphql(getGraphQLQuery(owner, repo, head_sha, prNumber)); core.info(`GraphQL Rate Limit Information: ${JSON.stringify(response.rateLimit)}`); - /** @type {WorkflowRunInfo[]} */ - let workflowRuns = []; - [reqCheckRuns, fyiCheckRuns, workflowRuns] = extractRunsFromGraphQLResponse(response); + [reqCheckRuns, fyiCheckRuns] = extractRunsFromGraphQLResponse(response); core.info( `RequiredCheckRuns: ${JSON.stringify(reqCheckRuns)}, ` + - `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}, ` + - `WorkflowRuns: ${JSON.stringify(workflowRuns)}`, + `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}, ` ); const filteredReqCheckRuns = reqCheckRuns.filter( /** @@ -661,7 +602,7 @@ export async function getCheckRunTuple( (checkRun) => !excludedCheckNames.includes(checkRun.name), ); - return [filteredReqCheckRuns, filteredFyiCheckRuns, workflowRuns]; + return [filteredReqCheckRuns, filteredFyiCheckRuns]; } /** @@ -686,28 +627,21 @@ export function checkRunIsSuccessful(checkRun) { } /** - * @param {GraphQLResponse} response - GraphQL response data - * @returns {[CheckRunData[], CheckRunData[], WorkflowRunInfo[]]} + * @param {any} response - GraphQL response data + * @returns {[CheckRunData[], CheckRunData[]]} */ function extractRunsFromGraphQLResponse(response) { /** @type {CheckRunData[]} */ const reqCheckRuns = []; /** @type {CheckRunData[]} */ const fyiCheckRuns = []; - /** @type {WorkflowRunInfo[]} */ - const workflowRuns = []; // Define the automated merging requirements check name if (response.resource?.checkSuites?.nodes) { response.resource.checkSuites.nodes.forEach( - /** @param {{ checkRuns?: { nodes?: any[] }, workflowRun?: WorkflowRunInfo }} checkSuiteNode */ + /** @param {{ checkRuns?: { nodes?: any[] } }} checkSuiteNode */ (checkSuiteNode) => { - // Extract workflow run information if available - if (checkSuiteNode.workflowRun) { - workflowRuns.push(checkSuiteNode.workflowRun); - } - if (checkSuiteNode.checkRuns?.nodes) { checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { // We have some specific guidance for some of the required checks. @@ -747,28 +681,7 @@ function extractRunsFromGraphQLResponse(response) { ); } - // Also extract workflow runs from the repository.object.associatedPullRequests path - if (response.repository?.object?.associatedPullRequests?.nodes) { - response.repository.object.associatedPullRequests.nodes.forEach(prNode => { - if (prNode.commits?.nodes) { - prNode.commits.nodes.forEach(commitNode => { - if (commitNode.commit?.checkSuites?.nodes) { - commitNode.commit.checkSuites.nodes.forEach(checkSuiteNode => { - if (checkSuiteNode.workflowRun) { - // Avoid duplicates by checking if we already have this workflow run - const existingRun = workflowRuns.find(run => run.id === checkSuiteNode.workflowRun.id); - if (!existingRun) { - workflowRuns.push(checkSuiteNode.workflowRun); - } - } - }); - } - }); - } - }); - } - - return [reqCheckRuns, fyiCheckRuns, workflowRuns]; + return [reqCheckRuns, fyiCheckRuns]; } // #endregion // #region next steps From 3717ef694837820146db8c8d4ea3d127eaf1a8d9 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 23:16:14 +0000 Subject: [PATCH 43/67] getting necessary metaadata in graphql. we should be able to download easily now --- .../src/summarize-checks/summarize-checks.js | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index f8ae0977aa3f..2a08a4817486 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -435,6 +435,13 @@ function getGraphQLQuery(owner, repo, sha, prNumber) { ... on Commit { checkSuites(first: 20) { nodes { + workflowRun { + id + databaseId + workflow { + name + } + } checkRuns(first: 30) { nodes { name @@ -640,7 +647,7 @@ function extractRunsFromGraphQLResponse(response) { if (response.resource?.checkSuites?.nodes) { response.resource.checkSuites.nodes.forEach( - /** @param {{ checkRuns?: { nodes?: any[] } }} checkSuiteNode */ + /** @param {{ workflowRun?: WorkflowRunInfo, checkRuns?: { nodes?: any[] } }} checkSuiteNode */ (checkSuiteNode) => { if (checkSuiteNode.checkRuns?.nodes) { checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { @@ -665,7 +672,7 @@ function extractRunsFromGraphQLResponse(response) { // Note the "else" here. It means that: // A GH check will be bucketed into "failing FYI check run" if: // - It is failing - // - AND is is NOT marked as 'required' in GitHub branch policy + // - AND it is is NOT marked as 'required' in GitHub branch policy // - AND it is marked as 'FYI' in this file's FYI_CHECK_NAMES array else if (FYI_CHECK_NAMES.includes(checkRunNode.name)) { fyiCheckRuns.push({ @@ -680,6 +687,26 @@ function extractRunsFromGraphQLResponse(response) { }, ); } + // Log Summarize PR Impact workflow run ID when a successful run is found + + + if (response.resource?.checkSuites?.nodes) { + response.resource.checkSuites.nodes.forEach( + /** @param {{ workflowRun?: WorkflowRunInfo, checkRuns?: { nodes?: any[] } }} checkSuiteNode */ + (checkSuiteNode) => { + if (checkSuiteNode.checkRuns?.nodes) { + checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { + if (checkRunNode.name === "[TEST-IGNORE] Summarize PR Impact" + && checkRunNode.status?.toLowerCase() === 'completed' + && checkRunNode.conclusion?.toLowerCase() === 'success') { + console.log( + `Found matching check run: ${checkRunNode.name} with workflow id of ${checkSuiteNode.workflowRun?.id}` + ); + } + }); + } + }); + } return [reqCheckRuns, fyiCheckRuns]; } From 92955879c7f4b53a723fedef4a5c4c470996396f Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 23:23:21 +0000 Subject: [PATCH 44/67] save progress --- .../src/summarize-checks/summarize-checks.js | 60 +++++++------------ 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 2a08a4817486..9e10a492d73a 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -687,25 +687,25 @@ function extractRunsFromGraphQLResponse(response) { }, ); } - // Log Summarize PR Impact workflow run ID when a successful run is found - + // extract the ImpactAssessment check run if it is completed and successful if (response.resource?.checkSuites?.nodes) { - response.resource.checkSuites.nodes.forEach( - /** @param {{ workflowRun?: WorkflowRunInfo, checkRuns?: { nodes?: any[] } }} checkSuiteNode */ - (checkSuiteNode) => { - if (checkSuiteNode.checkRuns?.nodes) { - checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { - if (checkRunNode.name === "[TEST-IGNORE] Summarize PR Impact" - && checkRunNode.status?.toLowerCase() === 'completed' - && checkRunNode.conclusion?.toLowerCase() === 'success') { - console.log( - `Found matching check run: ${checkRunNode.name} with workflow id of ${checkSuiteNode.workflowRun?.id}` - ); - } - }); + response.resource.checkSuites.nodes.forEach( + /** @param {{ workflowRun?: WorkflowRunInfo, checkRuns?: { nodes?: any[] } }} checkSuiteNode */ + (checkSuiteNode) => { + if (checkSuiteNode.checkRuns?.nodes) { + checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { + if (checkRunNode.name === "[TEST-IGNORE] Summarize PR Impact" + && checkRunNode.status?.toLowerCase() === 'completed' + && checkRunNode.conclusion?.toLowerCase() === 'success') { + console.log( + `Found matching check run: ${checkRunNode.name} with workflow id of ${checkSuiteNode.workflowRun?.id}` + ); + } + }); + } } - }); + ); } return [reqCheckRuns, fyiCheckRuns]; @@ -930,39 +930,21 @@ function buildViolatedLabelRulesNextStepsText(violatedRequiredLabelsRules) { // #region artifact downloading /** - * Downloads the job-summary artifact from the summarize-impact workflow + * Downloads the job-summary artifact for a given workflow run. * @param {import('@actions/github-script').AsyncFunctionArguments['github']} github * @param {typeof import("@actions/core")} core * @param {string} owner * @param {string} repo - * @param {string} head_sha + * @param {number} runId - The workflow run databaseId * @returns {Promise} The parsed job summary data */ -export async function downloadJobSummaryArtifact(github, core, owner, repo, head_sha) { +export async function downloadJobSummaryArtifact(github, core, owner, repo, runId) { try { - // Get workflow runs for the commit - const workflowRuns = await github.rest.actions.listWorkflowRunsForRepo({ - owner, - repo, - head_sha, - status: 'completed' - }); - - // Find the summarize-impact workflow run - const summarizeImpactRun = workflowRuns.data.workflow_runs.find(run => - run.name === '[TEST-IGNORE] Summarize PR Impact' - ); - - if (!summarizeImpactRun) { - core.info('No summarize-impact workflow run found for this commit'); - return null; - } - - // Get artifacts for the workflow run + // List artifacts for provided workflow run const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, - run_id: summarizeImpactRun.id + run_id: runId }); // Find the job-summary artifact From 01d83d5274a59eb55f357de0c663c7d5dc80b2d3 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Sun, 20 Jul 2025 23:51:13 +0000 Subject: [PATCH 45/67] download of the artifact now! --- .../src/summarize-checks/summarize-checks.js | 159 +++++++++--------- 1 file changed, 79 insertions(+), 80 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 9e10a492d73a..847638eaa1cb 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -324,8 +324,8 @@ export async function summarizeChecksImpl( } ); - /** @type {[CheckRunData[], CheckRunData[]]} */ - const [requiredCheckRuns, fyiCheckRuns] = await getCheckRunTuple( + /** @type {[CheckRunData[], CheckRunData[], import("./labelling.js").ImpactAssessment | undefined]} */ + const [requiredCheckRuns, fyiCheckRuns, impactAssessment] = await getCheckRunTuple( github, core, owner, @@ -340,7 +340,7 @@ export async function summarizeChecksImpl( // todo: how important is it that we know if we're in draft? I don't want to have to pull the PR details unless we actually need to // do we need to pull this from the PR? if triggered from a workflow_run we won't have payload.pullrequest populated - let labelContext = await updateLabels(labelNames, {}); + let labelContext = await updateLabels(labelNames, impactAssessment); for (const label of labelContext.toRemove) { core.info(`Removing label: ${label} from ${owner}/${repo}#${issue_number}.`); @@ -485,12 +485,12 @@ function warnIfLabelSetsIntersect(labelsToAdd, labelsToRemove) { // * @param {string | undefined } changedLabel /** * @param {string[]} existingLabels - * @param {Object} assessment + * @param {import("./labelling.js").ImpactAssessment | undefined} impactAssessment * @returns {import("./labelling.js").LabelContext} */ export function updateLabels( existingLabels, - assessment + impactAssessment ) { // logic for this function originally present in: // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts @@ -502,57 +502,37 @@ export function updateLabels( present: new Set(existingLabels), toAdd: new Set(), toRemove: new Set() - } - - console.log(`log ${assessment} to eliminate warning. Not actually populated yet. `); - - /** @type {import("./labelling.js").ImpactAssessment} */ - const impactAssessment = { - suppressionReviewRequired: false, - versioningReviewRequired: false, - breakingChangeReviewRequired: false, - rpaasChange: false, - newRP: false, - rpaasRPMissing: false, - rpaasRpNotInPrivateRepo: false, - resourceManagerRequired: false, - rpaasExceptionRequired: false, - typeSpecChanged: false, - isNewApiVersion: false, - isDraft: false, - labelContext: { - present: new Set(), - toAdd: new Set(), - toRemove: new Set() - }, - targetBranch: "main" }; - console.log(`Downloaded impact assessment: ${JSON.stringify(impactAssessment)}`); - // Merge impact assessment labels into the main labelContext - impactAssessment.labelContext.toAdd.forEach(label => { - labelContext.toAdd.add(label); - }); - impactAssessment.labelContext.toRemove.forEach(label => { - labelContext.toRemove.add(label); - }); + if (impactAssessment) { + console.log(`Downloaded impact assessment: ${JSON.stringify(impactAssessment)}`); + // Merge impact assessment labels into the main labelContext + impactAssessment.labelContext.toAdd.forEach(label => { + labelContext.toAdd.add(label); + }); + impactAssessment.labelContext.toRemove.forEach(label => { + labelContext.toRemove.add(label); + }); + } // this is the only labelling that was part of original pipelinebot logic processArmReviewLabels(labelContext, existingLabels); - // will further update the label context if necessary - processImpactAssessment( - impactAssessment.targetBranch, - labelContext, - impactAssessment.resourceManagerRequired, - impactAssessment.versioningReviewRequired, - impactAssessment.breakingChangeReviewRequired, - impactAssessment.rpaasRPMissing, - impactAssessment.rpaasExceptionRequired, - impactAssessment.rpaasRpNotInPrivateRepo, - impactAssessment.isNewApiVersion, - impactAssessment.isDraft - ); + if (impactAssessment) { + // will further update the label context if necessary + processImpactAssessment( + impactAssessment.targetBranch, + labelContext, + impactAssessment.resourceManagerRequired, + impactAssessment.versioningReviewRequired, + impactAssessment.breakingChangeReviewRequired, + impactAssessment.rpaasRPMissing, + impactAssessment.rpaasExceptionRequired, + impactAssessment.rpaasRpNotInPrivateRepo, + impactAssessment.isNewApiVersion, + impactAssessment.isDraft + ); + } warnIfLabelSetsIntersect(labelContext.toAdd, labelContext.toRemove) return labelContext; @@ -569,7 +549,7 @@ export function updateLabels( * @param {string} head_sha - The commit SHA to check. * @param {number} prNumber - The pull request number. * @param {string[]} excludedCheckNames - * @returns {Promise<[CheckRunData[], CheckRunData[]]>} + * @returns {Promise<[CheckRunData[], CheckRunData[], import("./labelling.js").ImpactAssessment | undefined]>} */ export async function getCheckRunTuple( github, @@ -587,14 +567,26 @@ export async function getCheckRunTuple( /** @type {CheckRunData[]} */ let fyiCheckRuns = []; + /** @type {number | undefined} */ + let impactAssessmentWorkflowRun = undefined; + + /** @type { import("./labelling.js").ImpactAssessment | undefined } */ + let impactAssessment = undefined; + const response = await github.graphql(getGraphQLQuery(owner, repo, head_sha, prNumber)); core.info(`GraphQL Rate Limit Information: ${JSON.stringify(response.rateLimit)}`); - [reqCheckRuns, fyiCheckRuns] = extractRunsFromGraphQLResponse(response); + [reqCheckRuns, fyiCheckRuns, impactAssessmentWorkflowRun] = extractRunsFromGraphQLResponse(response); + + if (impactAssessmentWorkflowRun) { + core.info(`Impact Assessment Workflow Run ID is present: ${impactAssessmentWorkflowRun}. Downloading job summary artifact`); + impactAssessment = await getImpactAssessment(github, core, owner, repo, impactAssessmentWorkflowRun); + } core.info( `RequiredCheckRuns: ${JSON.stringify(reqCheckRuns)}, ` + - `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}, ` + `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}, ` + + `ImpactAssessment: ${JSON.stringify(impactAssessment)}` ); const filteredReqCheckRuns = reqCheckRuns.filter( /** @@ -609,7 +601,7 @@ export async function getCheckRunTuple( (checkRun) => !excludedCheckNames.includes(checkRun.name), ); - return [filteredReqCheckRuns, filteredFyiCheckRuns]; + return [filteredReqCheckRuns, filteredFyiCheckRuns, impactAssessment]; } /** @@ -635,7 +627,7 @@ export function checkRunIsSuccessful(checkRun) { /** * @param {any} response - GraphQL response data - * @returns {[CheckRunData[], CheckRunData[]]} + * @returns {[CheckRunData[], CheckRunData[], number | undefined]} */ function extractRunsFromGraphQLResponse(response) { /** @type {CheckRunData[]} */ @@ -643,6 +635,9 @@ function extractRunsFromGraphQLResponse(response) { /** @type {CheckRunData[]} */ const fyiCheckRuns = []; + /** @type {number | undefined} */ + let impactAssessmentWorkflowRun = undefined; + // Define the automated merging requirements check name if (response.resource?.checkSuites?.nodes) { @@ -698,9 +693,8 @@ function extractRunsFromGraphQLResponse(response) { if (checkRunNode.name === "[TEST-IGNORE] Summarize PR Impact" && checkRunNode.status?.toLowerCase() === 'completed' && checkRunNode.conclusion?.toLowerCase() === 'success') { - console.log( - `Found matching check run: ${checkRunNode.name} with workflow id of ${checkSuiteNode.workflowRun?.id}` - ); + // Assign numeric databaseId, not the string node ID + impactAssessmentWorkflowRun = checkSuiteNode.workflowRun?.databaseId; } }); } @@ -708,7 +702,7 @@ function extractRunsFromGraphQLResponse(response) { ); } - return [reqCheckRuns, fyiCheckRuns]; + return [reqCheckRuns, fyiCheckRuns, impactAssessmentWorkflowRun]; } // #endregion // #region next steps @@ -936,9 +930,9 @@ function buildViolatedLabelRulesNextStepsText(violatedRequiredLabelsRules) { * @param {string} owner * @param {string} repo * @param {number} runId - The workflow run databaseId - * @returns {Promise} The parsed job summary data + * @returns {Promise} The parsed job summary data */ -export async function downloadJobSummaryArtifact(github, core, owner, repo, runId) { +export async function getImpactAssessment(github, core, owner, repo, runId) { try { // List artifacts for provided workflow run const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ @@ -954,10 +948,10 @@ export async function downloadJobSummaryArtifact(github, core, owner, repo, runI if (!jobSummaryArtifact) { core.info('No job-summary artifact found'); - return null; + return undefined; } - // Download the artifact + // Download the artifact as a zip archive const download = await github.rest.actions.downloadArtifact({ owner, repo, @@ -965,25 +959,30 @@ export async function downloadJobSummaryArtifact(github, core, owner, repo, runI archive_format: 'zip' }); - // The download.data is a buffer containing the zip file - // You'll need to extract and parse the JSON using JSZip - // const JSZip = require('jszip'); - // const zip = new JSZip(); - // const contents = await zip.loadAsync(download.data); - // const fileName = Object.keys(contents.files)[0]; - // const fileContent = await contents.files[fileName].async('text'); - // const jobSummary = JSON.parse(fileContent); - - core.info(`Successfully found job summary artifact with ID: ${jobSummaryArtifact.id}`); - return { - artifactId: jobSummaryArtifact.id, - downloadUrl: jobSummaryArtifact.archive_download_url, - data: download.data - }; - + core.info(`Successfully downloaded job-summary artifact ID: ${jobSummaryArtifact.id}`); + // Extract single JSON file from tar and parse + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { execSync } = require('child_process'); + // Write zip buffer to temp file and extract JSON + const tmpZip = path.join(os.tmpdir(), `job-summary-${runId}.zip`); + // Convert ArrayBuffer to Buffer + // Convert ArrayBuffer (download.data) to Node Buffer + const arrayBuffer = /** @type {ArrayBuffer} */ (download.data); + const zipBuffer = Buffer.from(new Uint8Array(arrayBuffer)); + fs.writeFileSync(tmpZip, zipBuffer); + // Extract JSON content from zip archive + const jsonContent = execSync(`unzip -p ${tmpZip}`); + fs.unlinkSync(tmpZip); + + /** @type {import("./labelling.js").ImpactAssessment} */ + // todo: we need to zod this to ensure the structure is correct, however we do not have zod installed at time of run + const impact = JSON.parse(jsonContent.toString('utf8')); + return impact; } catch (/** @type {any} */ error) { core.error(`Failed to download job summary artifact: ${error.message}`); - return null; + return undefined; } } // #endregion From ccc4a00368226df671b4228598c5bd82bde8c87a Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 00:08:33 +0000 Subject: [PATCH 46/67] we should get back the correct pr now --- .github/workflows/src/context.js | 18 ++++++++++++++++-- .github/workflows/src/issues.js | 15 +++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/src/context.js b/.github/workflows/src/context.js index a9848e1c1b88..6d08b2dc976f 100644 --- a/.github/workflows/src/context.js +++ b/.github/workflows/src/context.js @@ -163,7 +163,14 @@ export async function extractInputs(github, context, core) { // // In any case, the solution is to fall back to the (lower-rate-limit) search API. // The search API is confirmed to work in case #1, but has not been tested in #2 or #3. - issue_number = (await getIssueNumber({ head_sha, github, core })).issueNumber; + issue_number = ( + await getIssueNumber({ + head_sha, + github, + core, + repo: payload.workflow_run.repository.name + }) + ).issueNumber; } else if (pullRequests.length === 1) { issue_number = pullRequests[0].number; } else { @@ -222,8 +229,15 @@ export async function extractInputs(github, context, core) { try { // Fallback: search for PR number by commit SHA core.info(`Falling back to REST API to find PR for commit ${payload.workflow_run.head_sha}`); - const fallback = await getIssueNumber({ head_sha: payload.workflow_run.head_sha, github, core }); + const fallback = await getIssueNumber({ + head_sha: payload.workflow_run.head_sha, + github, + core, + owner: payload.workflow_run.repository.owner.login, + repo: payload.workflow_run.repository.name + }); issue_number = fallback.issueNumber; + } catch (error) { core.error(`Error: ${error}`); diff --git a/.github/workflows/src/issues.js b/.github/workflows/src/issues.js index 98ae32234616..d959c7946d0f 100644 --- a/.github/workflows/src/issues.js +++ b/.github/workflows/src/issues.js @@ -6,9 +6,11 @@ * @param {String} params.head_sha - The head_sha * @param {typeof import("@actions/core")} params.core - GitHub Actions core for logging * @param {import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api} params.github - GitHub API client + * @param {string} [params.owner] - Optional GitHub owner to filter by + * @param {string} [params.repo] - Optional GitHub repo name to filter by (requires owner) * @returns {Promise<{issueNumber: number}>} - The PR number or NaN if not found */ -export async function getIssueNumber({ head_sha, core, github }) { +export async function getIssueNumber({ head_sha, core, github, owner, repo }) { let issueNumber = NaN; if (!head_sha) { @@ -18,9 +20,14 @@ export async function getIssueNumber({ head_sha, core, github }) { core.info(`Searching for PRs with commit SHA: ${head_sha}`); try { - const searchResponse = await github.rest.search.issuesAndPullRequests({ - q: `sha:${head_sha} type:pr state:open`, - }); + // Build search query with optional owner/repo filtering + let query = `sha:${head_sha} type:pr state:open`; + if (owner && repo) { + query += ` repo:${owner}/${repo}`; + } else if (owner) { + query += ` user:${owner}`; + } + const searchResponse = await github.rest.search.issuesAndPullRequests({ q: query }); const totalCount = searchResponse.data.total_count; const itemsCount = searchResponse.data.items.length; From b7304f049c55513d1a0c9fb81e3631f6f2c50417 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 00:20:29 +0000 Subject: [PATCH 47/67] another small update to see if we can change our fallback --- .github/workflows/src/context.js | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/workflows/src/context.js b/.github/workflows/src/context.js index 6d08b2dc976f..093701d4ced3 100644 --- a/.github/workflows/src/context.js +++ b/.github/workflows/src/context.js @@ -168,7 +168,6 @@ export async function extractInputs(github, context, core) { head_sha, github, core, - repo: payload.workflow_run.repository.name }) ).issueNumber; } else if (pullRequests.length === 1) { @@ -222,26 +221,15 @@ export async function extractInputs(github, context, core) { } } } + if (!issue_number) { + core.info(`Attempting to fallback to first pullrequest number in pull_requests array: ${ payload.workflow_run.pull_requests?.[0]?.number }`); + issue_number = payload.workflow_run.pull_requests?.[0]?.number; + } + if (!issue_number) { core.info( `Could not find 'issue-number' artifact, which is required to associate the triggering workflow run with a PR`, ); - try { - // Fallback: search for PR number by commit SHA - core.info(`Falling back to REST API to find PR for commit ${payload.workflow_run.head_sha}`); - const fallback = await getIssueNumber({ - head_sha: payload.workflow_run.head_sha, - github, - core, - owner: payload.workflow_run.repository.owner.login, - repo: payload.workflow_run.repository.name - }); - issue_number = fallback.issueNumber; - - } - catch (error) { - core.error(`Error: ${error}`); - } } } else { throw new Error( From dd05dadfb1aa4a08405320d17aa393aace366e56 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 00:25:57 +0000 Subject: [PATCH 48/67] more debugging --- .github/workflows/src/context.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/src/context.js b/.github/workflows/src/context.js index 093701d4ced3..b87555bb1359 100644 --- a/.github/workflows/src/context.js +++ b/.github/workflows/src/context.js @@ -223,6 +223,9 @@ export async function extractInputs(github, context, core) { } if (!issue_number) { core.info(`Attempting to fallback to first pullrequest number in pull_requests array: ${ payload.workflow_run.pull_requests?.[0]?.number }`); + const prs = payload.workflow_run.pull_requests || []; + const baseOwner = payload.workflow_run.repository.owner.login; + console.log(`Ok What: ${JSON.stringify(payload.workflow_run.pull_requests)}`); issue_number = payload.workflow_run.pull_requests?.[0]?.number; } From 7e91cebbb13fa4bb4c91b5a23a031c433292eb9f Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 23:25:03 +0000 Subject: [PATCH 49/67] remove timeout from summarize-checks.test.js. ensure my integration tests are skipped --- .github/workflows/test/summarize-checks.test.js | 3 +-- eng/tools/summarize-impact/test/cli.test.ts | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 066f7c1a63c2..6ef72601c41c 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -609,8 +609,7 @@ describe("Summarize Checks Tests", () => { expect(labelsToAdd.sort()).toEqual(expectedLabelsToAdd.sort()); expect(labelsToRemove.sort()).toEqual(expectedLabelsToRemove.sort()); - }, - 600000, + } ); }); }); diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index c027cc1895c5..2f1413932c8e 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -6,10 +6,8 @@ import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files import { PRContext, LabelContext } from "../src/types.js"; import { evaluateImpact } from "../src/impact.js"; -// const REPOROOT = path.resolve(__dirname, "..", "..", "..", ".."); - describe("Check Changes", () => { - it.only("Integration test 35346", async () => { + it.skipIf(!process.env.GITHUB_TOKEN || !process.env.INTEGRATION_TEST)("Integration test 35346", async () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); @@ -53,7 +51,7 @@ describe("Check Changes", () => { } }, 60000000); - it("Integration test 35982", async () => { + it.skipIf(!process.env.GITHUB_TOKEN || !process.env.INTEGRATION_TEST)("Integration test 35982", async () => { const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); @@ -84,13 +82,13 @@ describe("Check Changes", () => { }); const result = await evaluateImpact(prContext, labelContext); - - // expect ARMReview, new-api-version, resource-manager, RPaaS, TypeSpec, WaitForARMFeedback - expect(result.isNewApiVersion).toBeTruthy(); expect(result.labelContext.toAdd.has("TypeSpec")).toBeTruthy(); expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.isNewApiVersion).toBeTruthy(); + expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); expect(result.labelContext.toAdd.has("RPaaS")).toBeTruthy(); + expect(result.labelContext.toAdd.has("WaitForARMFeedback")).toBeTruthy(); expect(result).toBeDefined(); } finally { // Restore original directory From 145c7935bce41f33c2da10e01c21aa88d92a8d13 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 23:27:37 +0000 Subject: [PATCH 50/67] restore context.js to main version --- .github/workflows/src/context.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/src/context.js b/.github/workflows/src/context.js index b87555bb1359..99e78b52eb56 100644 --- a/.github/workflows/src/context.js +++ b/.github/workflows/src/context.js @@ -163,13 +163,7 @@ export async function extractInputs(github, context, core) { // // In any case, the solution is to fall back to the (lower-rate-limit) search API. // The search API is confirmed to work in case #1, but has not been tested in #2 or #3. - issue_number = ( - await getIssueNumber({ - head_sha, - github, - core, - }) - ).issueNumber; + issue_number = (await getIssueNumber({ head_sha, github, core })).issueNumber; } else if (pullRequests.length === 1) { issue_number = pullRequests[0].number; } else { @@ -221,14 +215,6 @@ export async function extractInputs(github, context, core) { } } } - if (!issue_number) { - core.info(`Attempting to fallback to first pullrequest number in pull_requests array: ${ payload.workflow_run.pull_requests?.[0]?.number }`); - const prs = payload.workflow_run.pull_requests || []; - const baseOwner = payload.workflow_run.repository.owner.login; - console.log(`Ok What: ${JSON.stringify(payload.workflow_run.pull_requests)}`); - issue_number = payload.workflow_run.pull_requests?.[0]?.number; - } - if (!issue_number) { core.info( `Could not find 'issue-number' artifact, which is required to associate the triggering workflow run with a PR`, From c568b2ada85e46e3f17d352bcd4d33bbe271b257 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 23:30:20 +0000 Subject: [PATCH 51/67] revert issues.js to original from main --- .github/workflows/src/issues.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/src/issues.js b/.github/workflows/src/issues.js index d959c7946d0f..e4aa8835a45c 100644 --- a/.github/workflows/src/issues.js +++ b/.github/workflows/src/issues.js @@ -6,11 +6,9 @@ * @param {String} params.head_sha - The head_sha * @param {typeof import("@actions/core")} params.core - GitHub Actions core for logging * @param {import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api} params.github - GitHub API client - * @param {string} [params.owner] - Optional GitHub owner to filter by - * @param {string} [params.repo] - Optional GitHub repo name to filter by (requires owner) * @returns {Promise<{issueNumber: number}>} - The PR number or NaN if not found */ -export async function getIssueNumber({ head_sha, core, github, owner, repo }) { +export async function getIssueNumber({ head_sha, core, github }) { let issueNumber = NaN; if (!head_sha) { @@ -20,14 +18,9 @@ export async function getIssueNumber({ head_sha, core, github, owner, repo }) { core.info(`Searching for PRs with commit SHA: ${head_sha}`); try { - // Build search query with optional owner/repo filtering - let query = `sha:${head_sha} type:pr state:open`; - if (owner && repo) { - query += ` repo:${owner}/${repo}`; - } else if (owner) { - query += ` user:${owner}`; - } - const searchResponse = await github.rest.search.issuesAndPullRequests({ q: query }); + const searchResponse = await github.rest.search.issuesAndPullRequests({ + q: `sha:${head_sha} type:pr state:open`, + }); const totalCount = searchResponse.data.total_count; const itemsCount = searchResponse.data.items.length; @@ -54,3 +47,4 @@ export async function getIssueNumber({ head_sha, core, github, owner, repo }) { return { issueNumber }; } + From d69d8a56f0c9042ae8793efc7ce2ff7b4ac575e0 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 21 Jul 2025 23:46:18 +0000 Subject: [PATCH 52/67] add my package to tsconfig. add the issue number publication to summarize-pr-impact --- .github/workflows/summarize-checks.yaml | 2 ++ .github/workflows/summarize-impact.yaml | 16 +++++++++------- eng/tools/tsconfig.json | 3 +++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/summarize-checks.yaml b/.github/workflows/summarize-checks.yaml index f81cde86b7c5..4e39bd8fb5a4 100644 --- a/.github/workflows/summarize-checks.yaml +++ b/.github/workflows/summarize-checks.yaml @@ -48,6 +48,8 @@ jobs: sparse-checkout: | .github + # todo: add the npm install ci here for the zod usage in summarize-checks (not currently there) + - id: summarize-checks name: Summarize Checks uses: actions/github-script@v7 diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index bd0b442ccd31..210c60f02b5d 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -34,12 +34,6 @@ jobs: - name: Setup Node and install deps uses: ./.github/actions/setup-node-install-deps - # todo: why is this necessary? postinstall should already take care of the build? - - name: Build summarize-impact tool - run: | - cd eng/tools/summarize-impact - npm run build - - name: Get PR labels id: get-labels run: | @@ -65,7 +59,6 @@ jobs: # We absolutely need to avoid OOM errors due to certain inherited types from openapi-alps NODE_OPTIONS: "--max-old-space-size=8192" - # Used by other workflows like set-status - name: Set job-summary artifact if: ${{ always() && steps.summarize-impact.outputs.summary }} uses: actions/upload-artifact@v4 @@ -74,3 +67,12 @@ jobs: path: ${{ steps.summarize-impact.outputs.summary }} # If the file doesn't exist, just don't add the artifact if-no-files-found: ignore + + - if: | + always() && + github.event.pull_request.number > 0 + name: Upload artifact with issue number + uses: ./.github/actions/add-empty-artifact + with: + name: "issue-number" + value: "${{ github.event.pull_request.number }}" diff --git a/eng/tools/tsconfig.json b/eng/tools/tsconfig.json index ac5fe54c268f..ba7486de2512 100644 --- a/eng/tools/tsconfig.json +++ b/eng/tools/tsconfig.json @@ -41,5 +41,8 @@ { "path": "./typespec-validation", }, + { + "path": "./summarize-impact", + }, ], } From 58eaa8ac5e16233e6322b9d85aee92ac62e0d7c0 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:03:49 +0000 Subject: [PATCH 53/67] eliminate pointless checkout --- .github/workflows/summarize-impact.yaml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index 210c60f02b5d..cf5f54fa3629 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -12,13 +12,6 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Checkout eng - uses: actions/checkout@v4 - with: - sparse-checkout: | - eng/ - .github/ - - name: Checkout 'after' state uses: actions/checkout@v4 with: @@ -33,12 +26,14 @@ jobs: - name: Setup Node and install deps uses: ./.github/actions/setup-node-install-deps + with: + working-directory: after - name: Get PR labels id: get-labels run: | labels=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '[.labels[].name] | join(",")') - echo "labels=$labels" >> $GITHUB_OUTPUT + echo "labels=$labels" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ github.token }} @@ -48,7 +43,7 @@ jobs: run: | npm exec --no -- summarize-impact --sourceDirectory . --targetDirectory ../before \ --number "${{ github.event.pull_request.number }}" \ - --sourceBranch "${{ github.event.pull_request.head.ref }}" \ + --sourceBranch "$HEAD_REF" \ --targetBranch "${{ github.event.pull_request.base.ref }}" \ --sha "${{ github.event.pull_request.head.sha }}" \ --repo "${{ github.repository }}" \ @@ -58,6 +53,7 @@ jobs: env: # We absolutely need to avoid OOM errors due to certain inherited types from openapi-alps NODE_OPTIONS: "--max-old-space-size=8192" + HEAD_REF: ${{ github.event.pull_request.head.ref }} - name: Set job-summary artifact if: ${{ always() && steps.summarize-impact.outputs.summary }} From d4a56fa6ccbc2c6631804f568d50de1c35c80ca2 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:09:30 +0000 Subject: [PATCH 54/67] apply formatting fixes --- .github/shared/test/changed-files.test.js | 5 +- .github/workflows/src/issues.js | 1 - .../src/summarize-checks/labelling.js | 129 +++++++++--------- .../src/summarize-checks/summarize-checks.js | 103 +++++++------- .../workflows/test/summarize-checks.test.js | 49 ++++--- 5 files changed, 154 insertions(+), 133 deletions(-) diff --git a/.github/shared/test/changed-files.test.js b/.github/shared/test/changed-files.test.js index f3a4c6d46515..616accf55239 100644 --- a/.github/shared/test/changed-files.test.js +++ b/.github/shared/test/changed-files.test.js @@ -95,7 +95,10 @@ describe("changedFiles", () => { }); it("filter:typespec", () => { - const expected = ["specification/contosowidgetmanager/Contoso.Management/main.tsp"]; + const expected = [ + "specification/contosowidgetmanager/Contoso.Management/main.tsp", + "specification/contosowidgetmanager/Contoso.Management/tspconfig.yaml", + ]; expect(files.filter(typespec)).toEqual(expected); }); diff --git a/.github/workflows/src/issues.js b/.github/workflows/src/issues.js index e4aa8835a45c..98ae32234616 100644 --- a/.github/workflows/src/issues.js +++ b/.github/workflows/src/issues.js @@ -47,4 +47,3 @@ export async function getIssueNumber({ head_sha, core, github }) { return { issueNumber }; } - diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index 84fdf4fa3a40..775b3737dfde 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -232,32 +232,42 @@ export class Label { console.warn( "ASSERTION VIOLATION! " + `Cannot applyStateChange for label '${this.name}' ` + - "as its desired presence hasn't been defined. Returning early." + "as its desired presence hasn't been defined. Returning early.", ); return; } if (!this.present && this.shouldBePresent) { if (!labelsToAdd.has(this.name)) { - console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`); + console.log( + `Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`, + ); labelsToAdd.add(this.name); } else { - console.log(`Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`); + console.log( + `Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`, + ); } } else if (this.present && !this.shouldBePresent) { if (!labelsToRemove.has(this.name)) { - console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`); + console.log( + `Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`, + ); labelsToRemove.add(this.name); } else { - console.log(`Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`); + console.log( + `Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`, + ); } } else if (this.present === this.shouldBePresent) { - console.log(`Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`); + console.log( + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`, + ); } else { console.warn( "ASSERTION VIOLATION! " + - `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` - + `At this point of execution this should not happen.` + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` + + `At this point of execution this should not happen.`, ); } } @@ -267,9 +277,7 @@ export class Label { * @returns {boolean} */ isEqualToOrPrefixOf(label) { - return this.name.endsWith("*") - ? label.startsWith(this.name.slice(0, -1)) - : this.name === label; + return this.name.endsWith("*") ? label.startsWith(this.name.slice(0, -1)) : this.name === label; } /** @@ -396,16 +404,12 @@ export const breakingChangesCheckType = { // #endregion constants // #region Required Labels - /** * @param {import("./labelling.js").LabelContext} context * @param {string[]} existingLabels * @returns {void} */ -export function processArmReviewLabels( - context, - existingLabels -) { +export function processArmReviewLabels(context, existingLabels) { // the important part about how this will work depends how the users use it // EG: if they add the "ARMSignedOff" label, we will remove the "ARMChangesRequested" and "WaitForARMFeedback" labels. // if they add the "ARMChangesRequested" label, we will remove the "WaitForARMFeedback" label. @@ -421,7 +425,10 @@ export function processArmReviewLabels( } } // if there are ARM changes requested, we should remove the "WaitForARMFeedback" label as the presence indicates that ARM has reviewed - else if (containsAll(existingLabels, ["ARMChangesRequested"]) && containsNone(existingLabels, ["ARMSignedOff"])) { + else if ( + containsAll(existingLabels, ["ARMChangesRequested"]) && + containsNone(existingLabels, ["ARMSignedOff"]) + ) { if (existingLabels.includes("WaitForARMFeedback")) { context.toRemove.add("WaitForARMFeedback"); } @@ -499,7 +506,7 @@ export async function processImpactAssessment( isNewApiVersion, isDraft, ) { - console.log("ENTER definition processARMReview") + console.log("ENTER definition processARMReview"); const armReviewLabel = new Label("ARMReview", labelContext.present); // By default this label should not be present. We may determine later in this function that it should be present after all. @@ -510,18 +517,18 @@ export async function processImpactAssessment( newApiVersionLabel.shouldBePresent = false; const branch = targetBranch; - const isReleaseBranchVal = isReleaseBranch(branch) + const isReleaseBranchVal = isReleaseBranch(branch); // we used to also calculate if the branch name was from ShiftLeft, in which case we would or that // with isReleaseBranchVal to see if it's in scope of ARM review. ShiftLeft is not supported anymore, // so we only check if the branch is a release branch. // const prTitle = await getPrTitle(owner, repo, prNumber) // const isShiftLeftPRWithRPSaaSDevVal = isShiftLeftPRWithRPSaaSDev(prTitle, branch) - const isBranchInScopeOfSpecReview = isReleaseBranchVal// || isShiftLeftPRWithRPSaaSDevVal + const isBranchInScopeOfSpecReview = isReleaseBranchVal; // || isShiftLeftPRWithRPSaaSDevVal // 'specReviewApplies' means that either ARM or data-plane review applies. Downstream logic // determines which kind of review exactly we need. - let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview + let specReviewApplies = !isDraft && isBranchInScopeOfSpecReview; if (specReviewApplies) { if (isNewApiVersion) { // Note that in case of data-plane PRs, the addition of this label will result @@ -530,7 +537,7 @@ export async function processImpactAssessment( newApiVersionLabel.shouldBePresent = true; } - armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent + armReviewLabel.shouldBePresent = resourceManagerLabelShouldBePresent; await processARMReviewWorkflowLabels( labelContext, armReviewLabel.shouldBePresent, @@ -538,21 +545,24 @@ export async function processImpactAssessment( breakingChangeReviewRequiredLabelShouldBePresent, ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent) + ciRpaasRPNotInPrivateRepoLabelShouldBePresent, + ); } - newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) - armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove) + newApiVersionLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); + armReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); - console.log(`RETURN definition processARMReview. ` - + `isReleaseBranch: ${isReleaseBranchVal}, ` - + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` - + `isNewApiVersion: ${isNewApiVersion}, ` - + `isDraft: ${isDraft}, ` - + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` - + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`) + console.log( + `RETURN definition processARMReview. ` + + `isReleaseBranch: ${isReleaseBranchVal}, ` + + `isBranchInScopeOfArmReview: ${isBranchInScopeOfSpecReview}, ` + + `isNewApiVersion: ${isNewApiVersion}, ` + + `isDraft: ${isDraft}, ` + + `newApiVersionLabel.shouldBePresent: ${newApiVersionLabel.shouldBePresent}, ` + + `armReviewLabel.shouldBePresent: ${armReviewLabel.shouldBePresent}.`, + ); - return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent } + return { armReviewLabelShouldBePresent: armReviewLabel.shouldBePresent }; } /** @@ -611,36 +621,28 @@ async function processARMReviewWorkflowLabels( breakingChangeReviewRequiredLabelShouldBePresent, ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent + ciRpaasRPNotInPrivateRepoLabelShouldBePresent, ) { console.log("ENTER definition processARMReviewWorkflowLabels"); - const notReadyForArmReviewLabel = new Label( - "NotReadyForARMReview", - labelContext.present); + const notReadyForArmReviewLabel = new Label("NotReadyForARMReview", labelContext.present); - const waitForArmFeedbackLabel = new Label( - "WaitForARMFeedback", - labelContext.present - ); + const waitForArmFeedbackLabel = new Label("WaitForARMFeedback", labelContext.present); - const armChangesRequestedLabel = new Label( - "ARMChangesRequested", - labelContext.present - ); + const armChangesRequestedLabel = new Label("ARMChangesRequested", labelContext.present); const armSignedOffLabel = new Label("ARMSignedOff", labelContext.present); const blockedOnVersioningPolicy = getBlockedOnVersioningPolicy( labelContext, breakingChangeReviewRequiredLabelShouldBePresent, - versioningReviewRequiredLabelShouldBePresent + versioningReviewRequiredLabelShouldBePresent, ); const blockedOnRpaas = getBlockedOnRpaas( ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent + ciRpaasRPNotInPrivateRepoLabelShouldBePresent, ); const blocked = blockedOnVersioningPolicy || blockedOnRpaas; @@ -648,8 +650,7 @@ async function processARMReviewWorkflowLabels( // If given PR is in scope of ARM review and it is blocked for any reason, // the "NotReadyForARMReview" label should be present, to the exclusion // of all other ARM review workflow labels. - notReadyForArmReviewLabel.shouldBePresent = - armReviewLabelShouldBePresent && blocked; + notReadyForArmReviewLabel.shouldBePresent = armReviewLabelShouldBePresent && blocked; // If given PR is in scope of ARM review and the review is not blocked, // then "ARMSignedOff" label should remain present on the PR if it was @@ -657,9 +658,7 @@ async function processARMReviewWorkflowLabels( // and "WaitForARMFeedback" are invalid and will be removed by automation // in presence of "ARMSignedOff". armSignedOffLabel.shouldBePresent = - armReviewLabelShouldBePresent && - !blocked && - armSignedOffLabel.present; + armReviewLabelShouldBePresent && !blocked && armSignedOffLabel.present; // If given PR is in scope of ARM review and the review is not blocked and // not signed-off, then the label "ARMChangesRequested" should remain present @@ -684,16 +683,14 @@ async function processARMReviewWorkflowLabels( (waitForArmFeedbackLabel.present || true); const exactlyOneArmReviewWorkflowLabelShouldBePresent = - (Number(notReadyForArmReviewLabel.shouldBePresent) + + Number(notReadyForArmReviewLabel.shouldBePresent) + Number(armSignedOffLabel.shouldBePresent) + Number(armChangesRequestedLabel.shouldBePresent) + Number(waitForArmFeedbackLabel.shouldBePresent) === - 1) || !armReviewLabelShouldBePresent + 1 || !armReviewLabelShouldBePresent; if (!exactlyOneArmReviewWorkflowLabelShouldBePresent) { - console.warn( - "ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false" - ); + console.warn("ASSERTION VIOLATION! exactlyOneArmReviewWorkflowLabelShouldBePresent is false"); } notReadyForArmReviewLabel.applyStateChange(labelContext.toAdd, labelContext.toRemove); @@ -706,7 +703,7 @@ async function processARMReviewWorkflowLabels( `presentLabels: ${[...labelContext.present].join(",")}, ` + `blockedOnVersioningPolicy: ${blockedOnVersioningPolicy}. ` + `blockedOnRpaas: ${blockedOnRpaas}. ` + - `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. ` + `exactlyOneArmReviewWorkflowLabelShouldBePresent: ${exactlyOneArmReviewWorkflowLabelShouldBePresent}. `, ); return; } @@ -720,7 +717,7 @@ async function processARMReviewWorkflowLabels( function getBlockedOnVersioningPolicy( labelContext, breakingChangeReviewRequiredLabelShouldBePresent, - versioningReviewRequiredLabelShouldBePresent + versioningReviewRequiredLabelShouldBePresent, ) { const pendingVersioningReview = versioningReviewRequiredLabelShouldBePresent && @@ -730,8 +727,7 @@ function getBlockedOnVersioningPolicy( breakingChangeReviewRequiredLabelShouldBePresent && !anyApprovalLabelPresent("CrossVersion", [...labelContext.present]); - const blockedOnVersioningPolicy = - pendingVersioningReview || pendingBreakingChangeReview + const blockedOnVersioningPolicy = pendingVersioningReview || pendingBreakingChangeReview; return blockedOnVersioningPolicy; } @@ -744,11 +740,12 @@ function getBlockedOnVersioningPolicy( function getBlockedOnRpaas( ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent, rpaasExceptionLabelShouldBePresent, - ciRpaasRPNotInPrivateRepoLabelShouldBePresent -) -{ - return (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) - || ciRpaasRPNotInPrivateRepoLabelShouldBePresent + ciRpaasRPNotInPrivateRepoLabelShouldBePresent, +) { + return ( + (ciNewRPNamespaceWithoutRpaaSLabelShouldBePresent && !rpaasExceptionLabelShouldBePresent) || + ciRpaasRPNotInPrivateRepoLabelShouldBePresent + ); } // #endregion diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 847638eaa1cb..e4d9ac85a0fd 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -23,7 +23,13 @@ import { extractInputs } from "../context.js"; // eslint-disable-next-line no-unused-vars import { commentOrUpdate } from "../comment.js"; import { PER_PAGE_MAX } from "../github.js"; -import { verRevApproval, brChRevApproval, getViolatedRequiredLabelsRules, processArmReviewLabels, processImpactAssessment } from "./labelling.js"; +import { + verRevApproval, + brChRevApproval, + getViolatedRequiredLabelsRules, + processArmReviewLabels, + processImpactAssessment, +} from "./labelling.js"; import { brchTsg, @@ -314,15 +320,12 @@ export async function summarizeChecksImpl( ); // retrieve latest labels state - const labels = await github.paginate( - github.rest.issues.listLabelsOnIssue, - { - owner, - repo, - issue_number: issue_number, - per_page: PER_PAGE_MAX - } - ); + const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number: issue_number, + per_page: PER_PAGE_MAX, + }); /** @type {[CheckRunData[], CheckRunData[], import("./labelling.js").ImpactAssessment | undefined]} */ const [requiredCheckRuns, fyiCheckRuns, impactAssessment] = await getCheckRunTuple( @@ -372,7 +375,6 @@ export async function summarizeChecksImpl( } } - const commentBody = await createNextStepsComment( core, repo, @@ -472,12 +474,14 @@ function getGraphQLQuery(owner, repo, sha, prNumber) { * @param {Set} labelsToRemove */ function warnIfLabelSetsIntersect(labelsToAdd, labelsToRemove) { - const intersection = Array.from(labelsToAdd).filter(label => labelsToRemove.has(label)); + const intersection = Array.from(labelsToAdd).filter((label) => labelsToRemove.has(label)); if (intersection.length > 0) { - console.warn("ASSERTION VIOLATION! The intersection of labelsToRemove and labelsToAdd is non-empty! " - + `labelsToAdd: [${[...labelsToAdd].join(", ")}]. ` - + `labelsToRemove: [${[...labelsToRemove].join(", ")}]. ` - + `intersection: [${intersection.join(", ")}].`) + console.warn( + "ASSERTION VIOLATION! The intersection of labelsToRemove and labelsToAdd is non-empty! " + + `labelsToAdd: [${[...labelsToAdd].join(", ")}]. ` + + `labelsToRemove: [${[...labelsToRemove].join(", ")}]. ` + + `intersection: [${intersection.join(", ")}].`, + ); } } @@ -488,10 +492,7 @@ function warnIfLabelSetsIntersect(labelsToAdd, labelsToRemove) { * @param {import("./labelling.js").ImpactAssessment | undefined} impactAssessment * @returns {import("./labelling.js").LabelContext} */ -export function updateLabels( - existingLabels, - impactAssessment -) { +export function updateLabels(existingLabels, impactAssessment) { // logic for this function originally present in: // - private/openapi-kebab/src/bots/pipeline/pipelineBotOnPRLabelEvent.ts // - public/rest-api-specs-scripts/src/prSummary.ts @@ -501,16 +502,16 @@ export function updateLabels( const labelContext = { present: new Set(existingLabels), toAdd: new Set(), - toRemove: new Set() + toRemove: new Set(), }; if (impactAssessment) { console.log(`Downloaded impact assessment: ${JSON.stringify(impactAssessment)}`); // Merge impact assessment labels into the main labelContext - impactAssessment.labelContext.toAdd.forEach(label => { + impactAssessment.labelContext.toAdd.forEach((label) => { labelContext.toAdd.add(label); }); - impactAssessment.labelContext.toRemove.forEach(label => { + impactAssessment.labelContext.toRemove.forEach((label) => { labelContext.toRemove.add(label); }); } @@ -530,15 +531,14 @@ export function updateLabels( impactAssessment.rpaasExceptionRequired, impactAssessment.rpaasRpNotInPrivateRepo, impactAssessment.isNewApiVersion, - impactAssessment.isDraft + impactAssessment.isDraft, ); } - warnIfLabelSetsIntersect(labelContext.toAdd, labelContext.toRemove) + warnIfLabelSetsIntersect(labelContext.toAdd, labelContext.toRemove); return labelContext; } - // #endregion // #region checks /** @@ -576,17 +576,26 @@ export async function getCheckRunTuple( const response = await github.graphql(getGraphQLQuery(owner, repo, head_sha, prNumber)); core.info(`GraphQL Rate Limit Information: ${JSON.stringify(response.rateLimit)}`); - [reqCheckRuns, fyiCheckRuns, impactAssessmentWorkflowRun] = extractRunsFromGraphQLResponse(response); + [reqCheckRuns, fyiCheckRuns, impactAssessmentWorkflowRun] = + extractRunsFromGraphQLResponse(response); if (impactAssessmentWorkflowRun) { - core.info(`Impact Assessment Workflow Run ID is present: ${impactAssessmentWorkflowRun}. Downloading job summary artifact`); - impactAssessment = await getImpactAssessment(github, core, owner, repo, impactAssessmentWorkflowRun); + core.info( + `Impact Assessment Workflow Run ID is present: ${impactAssessmentWorkflowRun}. Downloading job summary artifact`, + ); + impactAssessment = await getImpactAssessment( + github, + core, + owner, + repo, + impactAssessmentWorkflowRun, + ); } core.info( `RequiredCheckRuns: ${JSON.stringify(reqCheckRuns)}, ` + `FyiCheckRuns: ${JSON.stringify(fyiCheckRuns)}, ` + - `ImpactAssessment: ${JSON.stringify(impactAssessment)}` + `ImpactAssessment: ${JSON.stringify(impactAssessment)}`, ); const filteredReqCheckRuns = reqCheckRuns.filter( /** @@ -690,15 +699,17 @@ function extractRunsFromGraphQLResponse(response) { (checkSuiteNode) => { if (checkSuiteNode.checkRuns?.nodes) { checkSuiteNode.checkRuns.nodes.forEach((checkRunNode) => { - if (checkRunNode.name === "[TEST-IGNORE] Summarize PR Impact" - && checkRunNode.status?.toLowerCase() === 'completed' - && checkRunNode.conclusion?.toLowerCase() === 'success') { - // Assign numeric databaseId, not the string node ID - impactAssessmentWorkflowRun = checkSuiteNode.workflowRun?.databaseId; + if ( + checkRunNode.name === "[TEST-IGNORE] Summarize PR Impact" && + checkRunNode.status?.toLowerCase() === "completed" && + checkRunNode.conclusion?.toLowerCase() === "success" + ) { + // Assign numeric databaseId, not the string node ID + impactAssessmentWorkflowRun = checkSuiteNode.workflowRun?.databaseId; } }); } - } + }, ); } @@ -938,16 +949,16 @@ export async function getImpactAssessment(github, core, owner, repo, runId) { const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, - run_id: runId + run_id: runId, }); // Find the job-summary artifact - const jobSummaryArtifact = artifacts.data.artifacts.find(artifact => - artifact.name === 'job-summary' + const jobSummaryArtifact = artifacts.data.artifacts.find( + (artifact) => artifact.name === "job-summary", ); if (!jobSummaryArtifact) { - core.info('No job-summary artifact found'); + core.info("No job-summary artifact found"); return undefined; } @@ -956,15 +967,15 @@ export async function getImpactAssessment(github, core, owner, repo, runId) { owner, repo, artifact_id: jobSummaryArtifact.id, - archive_format: 'zip' + archive_format: "zip", }); core.info(`Successfully downloaded job-summary artifact ID: ${jobSummaryArtifact.id}`); // Extract single JSON file from tar and parse - const fs = require('fs'); - const os = require('os'); - const path = require('path'); - const { execSync } = require('child_process'); + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const { execSync } = require("child_process"); // Write zip buffer to temp file and extract JSON const tmpZip = path.join(os.tmpdir(), `job-summary-${runId}.zip`); // Convert ArrayBuffer to Buffer @@ -978,7 +989,7 @@ export async function getImpactAssessment(github, core, owner, repo, runId) { /** @type {import("./labelling.js").ImpactAssessment} */ // todo: we need to zod this to ensure the structure is correct, however we do not have zod installed at time of run - const impact = JSON.parse(jsonContent.toString('utf8')); + const impact = JSON.parse(jsonContent.toString("utf8")); return impact; } catch (/** @type {any} */ error) { core.error(`Failed to download job summary artifact: ${error.message}`); diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 6ef72601c41c..5f6a56a50d38 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createNextStepsComment, summarizeChecksImpl, - updateLabels + updateLabels, } from "../src/summarize-checks/summarize-checks.js"; import { createMockCore } from "./mocks.js"; import { Octokit } from "@octokit/rest"; @@ -521,7 +521,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMChangesRequested", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested", "other-label"], expectedLabelsToAdd: [], - expectedLabelsToRemove: ["WaitForARMFeedback"] + expectedLabelsToRemove: ["WaitForARMFeedback"], }, { description: "labeled: ARMChangesRequested without WaitForARMFeedback", @@ -529,15 +529,20 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMChangesRequested", existingLabels: ["other-label", "ARMChangesRequested"], expectedLabelsToAdd: [], - expectedLabelsToRemove: [] + expectedLabelsToRemove: [], }, { description: "labeled: ARMSignedOff with both labels present", eventName: "labeled", changedLabel: "ARMSignedOff", - existingLabels: ["WaitForARMFeedback", "ARMSignedOff", "ARMChangesRequested", "other-label"], + existingLabels: [ + "WaitForARMFeedback", + "ARMSignedOff", + "ARMChangesRequested", + "other-label", + ], expectedLabelsToAdd: [], - expectedLabelsToRemove: ["WaitForARMFeedback", "ARMChangesRequested"] + expectedLabelsToRemove: ["WaitForARMFeedback", "ARMChangesRequested"], }, { description: "labeled: ARMSignedOff with only WaitForARMFeedback present", @@ -545,7 +550,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMSignedOff", existingLabels: ["WaitForARMFeedback", "ARMSignedOff", "other-label"], expectedLabelsToAdd: [], - expectedLabelsToRemove: ["WaitForARMFeedback"] + expectedLabelsToRemove: ["WaitForARMFeedback"], }, { description: "labeled: ARMSignedOff with only ARMChangesRequested present", @@ -553,7 +558,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMSignedOff", existingLabels: ["ARMChangesRequested", "ARMSignedOff", "other-label"], expectedLabelsToAdd: [], - expectedLabelsToRemove: ["ARMChangesRequested"] + expectedLabelsToRemove: ["ARMChangesRequested"], }, { description: "labeled: ARMSignedOff with neither label present", @@ -561,7 +566,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMSignedOff", existingLabels: ["other-label", "ARMSignedOff"], expectedLabelsToAdd: [], - expectedLabelsToRemove: [] + expectedLabelsToRemove: [], }, { description: "unlabeled: ARMChangesRequested with WaitForARMFeedback present (buggy case)", @@ -569,7 +574,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMChangesRequested", existingLabels: ["WaitForARMFeedback", "other-label"], expectedLabelsToAdd: [], - expectedLabelsToRemove: [] + expectedLabelsToRemove: [], }, { description: "unlabeled: ARMChangesRequested without WaitForARMFeedback", @@ -577,7 +582,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "ARMChangesRequested", existingLabels: ["other-label"], expectedLabelsToAdd: ["WaitForARMFeedback"], - expectedLabelsToRemove: [] + expectedLabelsToRemove: [], }, { description: "labeled: other label should have no effect", @@ -585,7 +590,7 @@ describe("Summarize Checks Tests", () => { changedLabel: "SomeOtherLabel", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], expectedLabelsToAdd: [], - expectedLabelsToRemove: ["WaitForARMFeedback"] + expectedLabelsToRemove: ["WaitForARMFeedback"], }, { description: "unlabeled: other label should have no effect", @@ -593,23 +598,29 @@ describe("Summarize Checks Tests", () => { changedLabel: "SomeOtherLabel", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], expectedLabelsToAdd: [], - expectedLabelsToRemove: ["WaitForARMFeedback"] - } + expectedLabelsToRemove: ["WaitForARMFeedback"], + }, ]; it.each(testCases)( "$description", - async ({ eventName, changedLabel, existingLabels, expectedLabelsToAdd, expectedLabelsToRemove }) => { + async ({ + eventName, + changedLabel, + existingLabels, + expectedLabelsToAdd, + expectedLabelsToRemove, + }) => { const [labelsToAdd, labelsToRemove] = await updateLabels( - eventName, - "main", - existingLabels, - changedLabel + eventName, + "main", + existingLabels, + changedLabel, ); expect(labelsToAdd.sort()).toEqual(expectedLabelsToAdd.sort()); expect(labelsToRemove.sort()).toEqual(expectedLabelsToRemove.sort()); - } + }, ); }); }); From 1408f26db502a8d442a372272eb7809903405031 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:17:47 +0000 Subject: [PATCH 55/67] expectation of the test should align with reality --- .github/shared/test/changed-files.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/shared/test/changed-files.test.js b/.github/shared/test/changed-files.test.js index 616accf55239..7ecf778a0740 100644 --- a/.github/shared/test/changed-files.test.js +++ b/.github/shared/test/changed-files.test.js @@ -84,6 +84,7 @@ describe("changedFiles", () => { const expected = [ "specification/contosowidgetmanager/data-plane/readme.md", "specification/contosowidgetmanager/Contoso.Management/main.tsp", + "specification/contosowidgetmanager/Contoso.Management/tspconfig.yaml", "specification/contosowidgetmanager/Contoso.Management/examples/2021-11-01/Employees_Get.json", "specification/contosowidgetmanager/resource-manager/readme.md", "specification/contosowidgetmanager/resource-manager/Microsoft.Contoso/stable/2021-11-01/contoso.json", From 119415fd16e4b62975e9755384d6c8d9a87a3f9d Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:21:30 +0000 Subject: [PATCH 56/67] call them frappropriately --- .github/workflows/summarize-impact.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index cf5f54fa3629..e2ab22edfa09 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -25,7 +25,7 @@ jobs: path: before - name: Setup Node and install deps - uses: ./.github/actions/setup-node-install-deps + uses: ./after/.github/actions/setup-node-install-deps with: working-directory: after @@ -68,7 +68,7 @@ jobs: always() && github.event.pull_request.number > 0 name: Upload artifact with issue number - uses: ./.github/actions/add-empty-artifact + uses: ./after/.github/actions/add-empty-artifact with: name: "issue-number" value: "${{ github.event.pull_request.number }}" From a3bd0ab3dcbc648bb8653ec6c52ae702673d7498 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:23:56 +0000 Subject: [PATCH 57/67] save label update --- .github/workflows/summarize-impact.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/summarize-impact.yaml b/.github/workflows/summarize-impact.yaml index e2ab22edfa09..b00d70dedc49 100644 --- a/.github/workflows/summarize-impact.yaml +++ b/.github/workflows/summarize-impact.yaml @@ -31,6 +31,7 @@ jobs: - name: Get PR labels id: get-labels + working-directory: after run: | labels=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '[.labels[].name] | join(",")') echo "labels=$labels" >> "$GITHUB_OUTPUT" From 0de8046af4093dfd4610e5caaa6de0974c743b75 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:31:46 +0000 Subject: [PATCH 58/67] fix the issues with the require vs import usage --- .../src/summarize-checks/summarize-checks.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index e4d9ac85a0fd..e7fc97132c86 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -41,6 +41,11 @@ import { typeSpecRequirementDataPlaneTsg, } from "./tsgs.js"; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execSync } from 'child_process'; + /** * @typedef {Object} CheckMetadata * @property {number} precedence @@ -971,11 +976,7 @@ export async function getImpactAssessment(github, core, owner, repo, runId) { }); core.info(`Successfully downloaded job-summary artifact ID: ${jobSummaryArtifact.id}`); - // Extract single JSON file from tar and parse - const fs = require("fs"); - const os = require("os"); - const path = require("path"); - const { execSync } = require("child_process"); + // Write zip buffer to temp file and extract JSON const tmpZip = path.join(os.tmpdir(), `job-summary-${runId}.zip`); // Convert ArrayBuffer to Buffer From 0fc5f4eaf701a6ebcbf4041514e808533108c101 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 00:54:38 +0000 Subject: [PATCH 59/67] split up the types as is reasonable. --- .../src/summarize-checks/summarize-checks.js | 8 +- eng/tools/summarize-impact/README.md | 2 +- .../summarize-impact/src/ImpactAssessment.ts | 18 + eng/tools/summarize-impact/src/PRContext.ts | 340 ++++++++++ eng/tools/summarize-impact/src/cli.ts | 3 +- eng/tools/summarize-impact/src/diff-types.ts | 40 ++ eng/tools/summarize-impact/src/impact.ts | 20 +- .../summarize-impact/src/labelling-types.ts | 123 ++++ eng/tools/summarize-impact/src/types.ts | 621 ------------------ eng/tools/summarize-impact/test/cli.test.ts | 167 ++--- 10 files changed, 627 insertions(+), 715 deletions(-) create mode 100644 eng/tools/summarize-impact/src/ImpactAssessment.ts create mode 100644 eng/tools/summarize-impact/src/PRContext.ts create mode 100644 eng/tools/summarize-impact/src/diff-types.ts create mode 100644 eng/tools/summarize-impact/src/labelling-types.ts diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index e7fc97132c86..e97d0d168d33 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -41,10 +41,10 @@ import { typeSpecRequirementDataPlaneTsg, } from "./tsgs.js"; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { execSync } from 'child_process'; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { execSync } from "child_process"; /** * @typedef {Object} CheckMetadata diff --git a/eng/tools/summarize-impact/README.md b/eng/tools/summarize-impact/README.md index 93f763790b97..674eddddae5e 100644 --- a/eng/tools/summarize-impact/README.md +++ b/eng/tools/summarize-impact/README.md @@ -21,4 +21,4 @@ export type ImpactAssessment = { isDraft: boolean; labelContext: LabelContext; }; -``` \ No newline at end of file +``` diff --git a/eng/tools/summarize-impact/src/ImpactAssessment.ts b/eng/tools/summarize-impact/src/ImpactAssessment.ts new file mode 100644 index 000000000000..c5693229f8bb --- /dev/null +++ b/eng/tools/summarize-impact/src/ImpactAssessment.ts @@ -0,0 +1,18 @@ +import { LabelContext } from "./labelling-types.js"; + +export type ImpactAssessment = { + resourceManagerRequired: boolean; + suppressionReviewRequired: boolean; + versioningReviewRequired: boolean; + breakingChangeReviewRequired: boolean; + isNewApiVersion: boolean; + rpaasExceptionRequired: boolean; + rpaasRpNotInPrivateRepo: boolean; + rpaasChange: boolean; + newRP: boolean; + rpaasRPMissing: boolean; + typeSpecChanged: boolean; + isDraft: boolean; + labelContext: LabelContext; + targetBranch: string; +}; diff --git a/eng/tools/summarize-impact/src/PRContext.ts b/eng/tools/summarize-impact/src/PRContext.ts new file mode 100644 index 000000000000..1038892c9724 --- /dev/null +++ b/eng/tools/summarize-impact/src/PRContext.ts @@ -0,0 +1,340 @@ +import { join, dirname } from "path"; +import * as fs from "fs"; + +import { parseMarkdown } from "@azure-tools/openapi-tools-common"; +import * as amd from "@azure/openapi-markdown"; + +import { SpecModel } from "@azure-tools/specs-shared/spec-model"; +import { Readme } from "@azure-tools/specs-shared/readme"; +import { + swagger, + typespec, + example, + readme, + specification, +} from "@azure-tools/specs-shared/changed-files"; + +import { LabelContext } from "./labelling-types.js"; +import { DiffResult, ReadmeTag, TagDiff, TagConfigDiff } from "./diff-types.js"; + +export type FileListInfo = { + additions: string[]; + modifications: string[]; + deletions: string[]; + renames: { from: string; to: string }[]; + total: number; +}; + +export type PRContextOptions = { + fileList?: FileListInfo; + sourceBranch: string; + targetBranch: string; + sha: string; + repo: string; + owner: string; + prNumber: string; + isDraft: boolean; +}; + +export class PRContext { + // we are starting with checking out before and after to different directories + // sourceDirectory corresponds to "after". EG the PR context is the "after" state of the PR. + // The "before" state is the git root directory without the changes aka targetDirectory + sourceDirectory: string; + targetDirectory: string; + sourceSpecModel?: SpecModel; + targetSpecModel?: SpecModel; + fileList?: FileListInfo; + sourceBranch: string; + targetBranch: string; + sha: string; + repo: string; + owner: string; + prNumber: string; + isDraft: boolean; + labelContext: LabelContext; + + constructor( + sourceDirectory: string, + targetDirectory: string, + labelContext: LabelContext, + options: PRContextOptions, + ) { + this.sourceDirectory = sourceDirectory; + this.targetDirectory = targetDirectory; + this.sourceSpecModel = new SpecModel(sourceDirectory); + this.targetSpecModel = new SpecModel(targetDirectory); + this.labelContext = labelContext; + this.sourceBranch = options.sourceBranch; + this.targetBranch = options.targetBranch; + this.sha = options.sha; + this.repo = options.repo; + this.prNumber = options.prNumber; + this.fileList = options.fileList; + this.isDraft = options.isDraft; + this.owner = options.owner; + } + + getChangedFiles(): string[] { + if (!this.fileList) { + return []; + } + + const changedFiles: string[] = (this.fileList.additions || []) + .concat(this.fileList.modifications || []) + .concat(this.fileList.deletions || []) + .concat(this.fileList.renames.map((r) => r.to) || []); + return changedFiles; + } + + // todo get rid of async here not necessary + // todo store the results + getTypeSpecDiffs(): DiffResult { + if (!this.fileList) { + return { + additions: [], + deletions: [], + changes: [], + }; + } + + const additions = this.fileList.additions.filter((file) => typespec(file)); + const deletions = this.fileList.deletions.filter((file) => typespec(file)); + const changes = this.fileList.modifications.filter((file) => typespec(file)); + + return { + additions, + deletions, + changes, + }; + } + + getSwaggerDiffs(): DiffResult { + if (!this.fileList) { + return { + additions: [], + deletions: [], + changes: [], + }; + } + + const additions = this.fileList.additions.filter((file) => swagger(file)); + const deletions = this.fileList.deletions.filter((file) => swagger(file)); + const changes = this.fileList.modifications.filter((file) => swagger(file)); + + return { + additions, + deletions, + changes, + }; + } + + getExampleDiffs(): DiffResult { + if (!this.fileList) { + return { + additions: [], + deletions: [], + changes: [], + }; + } + + const additions = this.fileList.additions.filter((file) => example(file)); + const deletions = this.fileList.deletions.filter((file) => example(file)); + const changes = this.fileList.modifications.filter((file) => example(file)); + + return { + additions, + deletions, + changes, + }; + } + + async getTagsFromReadme(readmePath: string): Promise { + const tags = await new Readme(readmePath).getTags(); + return [...tags.values()].map((tag) => tag.name); + } + + async getPossibleParentConfigurations(): Promise { + console.log("ENTER definition getPossibleParentConfigurations"); + const changedFiles = await this.getChangedFiles(); + console.log(`Detect changes in the PR:\n${JSON.stringify(changedFiles, null, 2)}`); + const readmes = changedFiles.filter((f) => readme(f)); + + const visitedFolder = new Set(); + changedFiles + .filter((f) => [".md", ".json", ".yaml", ".yml"].some((p) => f.endsWith(p))) + .forEach((f) => { + let dir = dirname(f); + if (visitedFolder.has(dir)) { + return; + } + while (specification(dir)) { + if (visitedFolder.has(dir)) { + break; + } + visitedFolder.add(dir); + const possibleReadme = join(dir, "readme.md"); + if (fs.existsSync(possibleReadme)) { + if (!readmes.includes(possibleReadme)) { + readmes.push(possibleReadme); + } + break; + } + dir = dirname(dir); + } + }); + console.log("RETURN definition getPossibleParentConfigurations"); + return readmes; + } + + getAllTags(readMeContent: string): string[] { + // todo: we should refactor this to use the spec model, but I haven't had the the time to explicitly + // diff what oad does does here, so I'm leaving it alone for now until I can build some strong unit tests around this. + const cmd = parseMarkdown(readMeContent); + const allTags = new amd.ReadMeManipulator( + { error: (_msg: string) => {} }, + new amd.ReadMeBuilder(), + ).getAllTags(cmd); + return [...allTags]; + } + + async getInputFiles(readMeContent: string, tag: string) { + // todo: we should refactor this to use spec model, but I haven't had time to isolate exactly what + // openapi-markdown is doing here, so I'm just going to use the same logic for now + const cmd = parseMarkdown(readMeContent); + return amd.getInputFilesForTag(cmd.markDown, tag); + } + + async getChangingTags(): Promise { + // we are retrieving all the readme changes, no matter if they're additions, deletions, etc + // Additionally, we're also retrieving all the readme files that may be affected by the changes in the PR, which means + // climbing up the directory tree until we find a readme.md file if necessary. + const allAffectedReadmes: string[] = await this.getPossibleParentConfigurations(); + console.log(`all affected readme are:`); + console.log(JSON.stringify(allAffectedReadmes, null, 2)); + const Diffs: TagDiff[] = []; + for (const readme of allAffectedReadmes) { + const oldReadme = join(this.targetDirectory, readme); + const newReadme = join(this.sourceDirectory, readme); + // As the readme may be not existing , need to check if it's existing. + // we are checking target and source individually, because the readme may not exist in the target branch + const oldTags: string[] = fs.existsSync(oldReadme) + ? [...(await new Readme(oldReadme).getTags()).keys()] + : []; + const newTags: string[] = fs.existsSync(newReadme) + ? [...(await new Readme(newReadme).getTags()).keys()] + : []; + const intersect = oldTags + .filter((t) => newTags.includes(t)) + .filter((tag) => tag !== "all-api-versions"); + const insertions = newTags.filter((t) => !oldTags.includes(t)); + const deletions = oldTags.filter((t) => !newTags.includes(t)); + const differences: TagConfigDiff[] = []; + + // todo: we need to ensure we get ALL effected swaggers by their relationships, not just the swagger files that are directly changed in the PR. + // right now I'm just going to filter to the ones that are directly changed in the PR. + // this is a temporary solution, we need to ensure we get all of the swagger files + // that are affected by the changes in the readme. SpecModel will be useful for this + // we want to get all of the swagger files that are affected by the changes in the readme. + // we can do that using the specmodel + // const allAffectedInputFiles = await this.getRealAffectedSwagger(readme) + // talk to Mike and ask him how we could get all affected swagger files from a readme path. + // I want to say that readme(readme).specModel.getAffectedSwaggerFiles will work? + const allAffectedInputFiles = await (await this.getChangedFiles()).filter((f) => swagger(f)); + console.log(`all affected swagger files in ${readme} are:`); + console.log(JSON.stringify(allAffectedInputFiles, null, 2)); + const getChangedInputFiles = async (tag: string) => { + const readmeContent = await fs.promises.readFile(newReadme, "utf-8"); + const inputFiles = await this.getInputFiles(readmeContent, tag); + if (inputFiles) { + const changedInputFiles = (inputFiles as string[]).filter((f) => + allAffectedInputFiles.some((a) => a.endsWith(f)), + ); + return changedInputFiles; + } + return []; + }; + const changes: string[] = []; + for (const tag of intersect) { + const tagDiff: TagConfigDiff = { name: tag }; + const changedInputFiles = await getChangedInputFiles(tag); + if (changedInputFiles.length) { + console.log("found changed input files under tag:" + tag); + tagDiff.changedInputFiles = changedInputFiles; + changes.push(tag); + } + } + Diffs.push({ readme, insertions, deletions, changes, differences }); + } + return Diffs; + } + + // this function is based upon LocalDirContext.getReadmeDiffs() and CommonPRContext.getReadmeDiffs() which are + // very different from each other (because why not). This implementation is based MOSTLY on CommonPRContext + async getReadmeDiffs(): Promise> { + // this gets all of the readme diffs + // const readmeAdditions = this.fileList?.additions.filter(file => readme(file)) || []; + // const readmeChanges = this.fileList?.modifications.filter(file => readme(file)) + // const readmeDeletions = this.fileList?.deletions.filter(file => readme(file)); + //const changedFiles: DiffFileResult | undefined = await this.localPRContext?.getChangingFiles(); + + const changedFiles = await this.fileList; + const tagDiffs = (await this.getChangingTags()) || []; + + const readmeTagDiffs = tagDiffs + ?.filter((tagDiff: TagDiff) => readme(tagDiff.readme)) + .map((tagDiff: TagDiff) => { + return { + readme: tagDiff.readme, + tags: { + changes: tagDiff.changes, + deletions: tagDiff.deletions, + additions: tagDiff.insertions, + }, + } as ReadmeTag; + }); + + const readmeTagDiffsInAddedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( + (readmeTag: ReadmeTag): boolean => + Boolean(changedFiles?.additions.includes(readmeTag.readme)), + ); + const readmeTagDiffsInDeletedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( + (readmeTag: ReadmeTag): boolean => + Boolean(changedFiles?.deletions.includes(readmeTag.readme)), + ); + const readmeTagDiffsInChangedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( + (readmeTag: ReadmeTag): boolean => { + // The README file that contains the API version tags that have been diffed in given readmeTag (i.e. readmeTag.tags) + // has been modified. + const readmeModified = changedFiles?.modifications.includes(readmeTag.readme); + + // The README was not modified, added or deleted; just the specs belonging to the API version tags in the README were modified. + // In such case we assume the README is 'changed' in the sense the specs belonging to the API version tags in the README were modified. + // The README file itself wasn't modified. + // We assume here the README is present in the repository - otherwise it would show up as 'deleted' or would not + // appear as readmeTag.readme in any of the readmeTagDiffs. + const readmeUnchanged = + !changedFiles?.additions.includes(readmeTag.readme) && + !changedFiles?.deletions.includes(readmeTag.readme); + + return readmeModified || readmeUnchanged; + }, + ); + + const result = { + additions: readmeTagDiffsInAddedReadmeFiles, + deletions: readmeTagDiffsInDeletedReadmeFiles, + changes: readmeTagDiffsInChangedReadmeFiles, + }; + + console.log( + `RETURN definition CommonPRContext.getReadmeDiffs. ` + + `changedFiles: ${JSON.stringify(changedFiles)}, ` + + `tagDiffs: ${JSON.stringify(tagDiffs)}, ` + + `readmeTagDiffs: ${JSON.stringify(readmeTagDiffs)}, ` + + `this.readmeDiffs: ${JSON.stringify(result)}.`, + ); + + return result; + } +} diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index bf482c49a325..303c12f3f4f1 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -8,7 +8,8 @@ import { resolve, join } from "path"; import fs from "fs"; import { parseArgs, ParseArgsConfig } from "node:util"; import { simpleGit } from "simple-git"; -import { LabelContext, PRContext } from "./types.js"; +import { LabelContext } from "./labelling-types.js"; +import { PRContext } from "./PRContext.js"; export async function getRootFolder(inputPath: string): Promise { try { diff --git a/eng/tools/summarize-impact/src/diff-types.ts b/eng/tools/summarize-impact/src/diff-types.ts new file mode 100644 index 000000000000..c34f851a873c --- /dev/null +++ b/eng/tools/summarize-impact/src/diff-types.ts @@ -0,0 +1,40 @@ +export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; +export type ChangeTypes = "Addition" | "Deletion" | "Update"; + +export type PRChange = { + fileType: FileTypes; + changeType: ChangeTypes; + filePath: string; + additionalInfo?: any; +}; + +export type ReadmeTag = { + readme: string; + tags: DiffResult; +}; + +export type TagConfigDiff = { + name: string; + oldConfig?: any; + newConfig?: any; + difference?: any; + changedInputFiles?: string[]; +}; + +export type TagDiff = { + readme: string; + changes: string[]; + insertions: string[]; + deletions: string[]; + differences?: TagConfigDiff[]; +}; + +export type ChangeHandler = { + [key in FileTypes]?: (event: PRChange) => void | Promise; +}; + +export type DiffResult = { + additions?: T[]; + deletions?: T[]; + changes?: T[]; +}; diff --git a/eng/tools/summarize-impact/src/impact.ts b/eng/tools/summarize-impact/src/impact.ts index 09965e736916..757521c8b227 100644 --- a/eng/tools/summarize-impact/src/impact.ts +++ b/eng/tools/summarize-impact/src/impact.ts @@ -9,19 +9,21 @@ import yaml from "js-yaml"; import * as _ from "lodash"; import { breakingChangesCheckType } from "@azure-tools/specs-shared/breaking-change"; + import { + DiffResult, + ReadmeTag, FileTypes, ChangeTypes, PRChange, ChangeHandler, - PRType, - ImpactAssessment, - LabelContext, - PRContext, - Label, - DiffResult, - ReadmeTag, -} from "./types.js"; +} from "./diff-types.js"; + +import { PRType, Label, LabelContext } from "./labelling-types.js"; + +import { ImpactAssessment } from "./ImpactAssessment.js"; +import { PRContext } from "./PRContext.js"; + import { Readme } from "@azure-tools/specs-shared/readme"; export const breakingChangeLabelVarName = "breakingChangeVar"; @@ -162,7 +164,7 @@ export async function evaluateImpact( isNewApiVersion: newApiVersion, isDraft: context.isDraft, labelContext: labelContext, - targetBranch: context.targetBranch + targetBranch: context.targetBranch, }; } diff --git a/eng/tools/summarize-impact/src/labelling-types.ts b/eng/tools/summarize-impact/src/labelling-types.ts new file mode 100644 index 000000000000..fbd3bccfb616 --- /dev/null +++ b/eng/tools/summarize-impact/src/labelling-types.ts @@ -0,0 +1,123 @@ +export type PRType = "resource-manager" | "data-plane"; + +/** + * The LabelContext is used by prSummary.ts / summary() and downstream invocations. + * + * The "present" set represents the set of labels that are currently present on the PR + * processed by given invocation of summary(). It is obtained via GitHub Octokit API at the beginning + * of summary(). + * + * The "toAdd" set is the set of labels to be added to the PR at the end of invocation of summary(). + * This is to be done by calling GitHub Octokit API to add the labels. + * + * The "toRemove" set is analogous to "toAdd" set, but instead it is the set of labels to be removed. + * + * The general pattern used in the code to populate "toAdd" or "toRemove" sets to be ready for + * Octokit invocation is as follows: + * + * - the summary() function passes the context through its invocation chain. + * - given function responsible for given label, like e.g. for label "ARMReview", + * creates a new instance of Label: const armReviewLabel = new Label("ARMReview", labelContext.present) + * - the function then processes the label to determine if armReviewLabel.shouldBePresent is to be set to true or false. + * - the function at the end of its invocation calls armReviewLabel.applyStateChanges(labelContext.toAdd, labelContext.toRemove) + * to update the sets. + * - the function may optionally return { armReviewLabel.shouldBePresent } to allow the caller to pass this value + * further to downstream business logic that depends on it. + * - at the end of invocation summary() calls Octokit passing it as input labelContext.toAdd and labelContext.toRemove. + * + * todo: this type is duplicated in JSDoc over in summarize-checks.js + */ +export type LabelContext = { + present: Set; + toAdd: Set; + toRemove: Set; +}; + +export class Label { + name: string; + + /** Is the label currently present on the pull request? + * + * This is determined at the time of construction of this object. + */ + present?: boolean; + + /** Should this label be present on the pull request? + * + * Must be defined before applyStateChange is called. + * + * Not set at the construction time to facilitate determining desired presence + * of multiple labels in single code block, without intermixing it with + * label construction logic. + */ + shouldBePresent: boolean | undefined = undefined; + + constructor(name: string, presentLabels?: Set) { + this.name = name; + this.present = presentLabels?.has(this.name) ?? undefined; + } + + /** + * If the label should be added, add its name to labelsToAdd. + * If the label should be removed, add its name to labelsToRemove. + * Otherwise, do nothing. + * + * Precondition: this.shouldBePresent has been defined. + */ + applyStateChange(labelsToAdd: Set, labelsToRemove: Set): void { + if (this.shouldBePresent === undefined) { + console.warn( + "ASSERTION VIOLATION! " + + `Cannot applyStateChange for label '${this.name}' ` + + "as its desired presence hasn't been defined. Returning early.", + ); + return; + } + + if (!this.present && this.shouldBePresent) { + if (!labelsToAdd.has(this.name)) { + console.log( + `Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`, + ); + labelsToAdd.add(this.name); + } else { + console.log( + `Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`, + ); + } + } else if (this.present && !this.shouldBePresent) { + if (!labelsToRemove.has(this.name)) { + console.log( + `Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`, + ); + labelsToRemove.add(this.name); + } else { + console.log( + `Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`, + ); + } + } else if (this.present === this.shouldBePresent) { + console.log( + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`, + ); + } else { + console.warn( + "ASSERTION VIOLATION! " + + `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` + + `At this point of execution this should not happen.`, + ); + } + } + + isEqualToOrPrefixOf(label: string): boolean { + return this.name.endsWith("*") ? label.startsWith(this.name.slice(0, -1)) : this.name === label; + } + + logString(): string { + return ( + `Label: name: ${this.name}, ` + + `present: ${this.present}, ` + + `shouldBePresent: ${this.shouldBePresent}. ` + ); + } +} diff --git a/eng/tools/summarize-impact/src/types.ts b/eng/tools/summarize-impact/src/types.ts index e6ad0120e84e..e69de29bb2d1 100644 --- a/eng/tools/summarize-impact/src/types.ts +++ b/eng/tools/summarize-impact/src/types.ts @@ -1,621 +0,0 @@ -import { join, dirname } from "path"; -import * as amd from "@azure/openapi-markdown"; -import { parseMarkdown } from "@azure-tools/openapi-tools-common"; -import * as fs from "fs"; - -import { SpecModel } from "@azure-tools/specs-shared/spec-model"; -import { - swagger, - typespec, - example, - readme, - specification, -} from "@azure-tools/specs-shared/changed-files"; -import { Readme } from "@azure-tools/specs-shared/readme"; - -export type FileTypes = "SwaggerFile" | "TypeSpecFile" | "ExampleFile" | "ReadmeFile"; -export type ChangeTypes = "Addition" | "Deletion" | "Update"; - -export type PRChange = { - fileType: FileTypes; - changeType: ChangeTypes; - filePath: string; - additionalInfo?: any; -}; - -export type ReadmeTag = { - readme: string; - tags: DiffResult; -}; - -export type TagConfigDiff = { - name: string; - oldConfig?: any; - newConfig?: any; - difference?: any; - changedInputFiles?: string[]; -}; - -export type TagDiff = { - readme: string; - changes: string[]; - insertions: string[]; - deletions: string[]; - differences?: TagConfigDiff[]; -}; - -export type ChangeHandler = { - [key in FileTypes]?: (event: PRChange) => void | Promise; -}; - -// type Pattern = { -// includes: string[] | string; -// excludes?: string[] | string; -// }; - -export type DiffResult = { - additions?: T[]; - deletions?: T[]; - changes?: T[]; -}; - -// do I need to add minimatch as a dependency? What does it do? -// export const isPathMatch = ( -// path: string, -// pattern: string[] | string -// ): boolean => { -// if (typeof pattern === "string") { -// return minimatch(path, pattern, minimatchConfig); -// } else { -// return pattern.some((it) => minimatch(path, it, minimatchConfig)); -// } -// }; - -// const isPatternMatch = (path: string, pattern: Pattern): boolean => { -// if (pattern.excludes) { -// if (isPathMatch(path, pattern.excludes)) { -// return false; -// } -// } -// return isPathMatch(path, pattern.includes); -// }; - -// export const enumerateFiles = async (path: string, pattern: Pattern) => { -// let results: any[] = []; -// if (await exists(path)) { -// const runEnumerateFiles = ( -// include: string, -// excludes: string[] | string | undefined -// ) => { -// const absolutelyPath = join(path, include); -// let ignores: string[] = []; -// if (excludes) { -// ignores = ignores.concat(excludes); -// } -// const files = glob.sync(absolutelyPath, { -// ignore: ignores, -// }); -// return files; -// }; - -// if (Array.isArray(pattern.includes)) { -// for (const pat of pattern.includes) { -// results = results.concat(runEnumerateFiles(pat, pattern.excludes)); -// } -// } else { -// results = runEnumerateFiles(pattern.includes, pattern.excludes); -// } -// } -// return results; -// }; - -// const exists = async (path: string) => { -// return fs.existsSync(path); -// }; - -// const defaultFilePatterns = { -// example: { -// includes: "**/examples/**/*.json", -// excludes: ["**/quickstart-templates/*.json", "**/schema/*.json", "**/scenarios/**/*.json","**/cadl/*.json"], -// } as Pattern, - -// swagger: { -// includes: "**/*.json", -// excludes: [ -// "**/quickstart-templates/*.json", -// "**/schema/*.json", -// "**/scenarios/**/*.json", -// "**/examples/**/*.json", -// "**/package.json", -// "**/package-lock.json", -// "**/cadl/**/*.json" -// ], -// } as Pattern, - -// cadl: { -// includes: "**/*.cadl", -// } as Pattern, - -// typespec: { -// includes: [ -// "**/*.tsp", -// // We do not include tspconfig.yml because by design it is invalid. The PR should have tspconfig.yaml instead. -// // If a PR author adds tspconfig.yml the TypeSpec validation will report issue, blocking the PR from proceeding. -// // Source code of this validation can be found at: -// // https://github.com/Azure/azure-rest-api-specs/blob/b8c74fd80b415fa1ebb6fa787d454694c39e0fd5/eng/tools/typespec-validation/src/rules/folder-structure.ts#L27C27-L27C41 -// // "**/*tspconfig.yml", -// "**/*tspconfig.yaml" -// ] -// } as Pattern, - -// readme: { includes: "**/readme.md" } as Pattern, -// }; - -export type PRType = "resource-manager" | "data-plane"; - -/** - * The LabelContext is used by prSummary.ts / summary() and downstream invocations. - * - * The "present" set represents the set of labels that are currently present on the PR - * processed by given invocation of summary(). It is obtained via GitHub Octokit API at the beginning - * of summary(). - * - * The "toAdd" set is the set of labels to be added to the PR at the end of invocation of summary(). - * This is to be done by calling GitHub Octokit API to add the labels. - * - * The "toRemove" set is analogous to "toAdd" set, but instead it is the set of labels to be removed. - * - * The general pattern used in the code to populate "toAdd" or "toRemove" sets to be ready for - * Octokit invocation is as follows: - * - * - the summary() function passes the context through its invocation chain. - * - given function responsible for given label, like e.g. for label "ARMReview", - * creates a new instance of Label: const armReviewLabel = new Label("ARMReview", labelContext.present) - * - the function then processes the label to determine if armReviewLabel.shouldBePresent is to be set to true or false. - * - the function at the end of its invocation calls armReviewLabel.applyStateChanges(labelContext.toAdd, labelContext.toRemove) - * to update the sets. - * - the function may optionally return { armReviewLabel.shouldBePresent } to allow the caller to pass this value - * further to downstream business logic that depends on it. - * - at the end of invocation summary() calls Octokit passing it as input labelContext.toAdd and labelContext.toRemove. - * - * todo: this type is duplicated in JSDoc over in summarize-checks.js - */ -export type LabelContext = { - present: Set; - toAdd: Set; - toRemove: Set; -}; - -export type ImpactAssessment = { - resourceManagerRequired: boolean; - suppressionReviewRequired: boolean; - versioningReviewRequired: boolean; - breakingChangeReviewRequired: boolean; - isNewApiVersion: boolean; - rpaasExceptionRequired: boolean; - rpaasRpNotInPrivateRepo: boolean; - rpaasChange: boolean; - newRP: boolean; - rpaasRPMissing: boolean; - typeSpecChanged: boolean; - isDraft: boolean; - labelContext: LabelContext; - targetBranch: string; -}; - -export class Label { - name: string; - - /** Is the label currently present on the pull request? - * - * This is determined at the time of construction of this object. - */ - present?: boolean; - - /** Should this label be present on the pull request? - * - * Must be defined before applyStateChange is called. - * - * Not set at the construction time to facilitate determining desired presence - * of multiple labels in single code block, without intermixing it with - * label construction logic. - */ - shouldBePresent: boolean | undefined = undefined; - - constructor(name: string, presentLabels?: Set) { - this.name = name; - this.present = presentLabels?.has(this.name) ?? undefined; - } - - /** - * If the label should be added, add its name to labelsToAdd. - * If the label should be removed, add its name to labelsToRemove. - * Otherwise, do nothing. - * - * Precondition: this.shouldBePresent has been defined. - */ - applyStateChange(labelsToAdd: Set, labelsToRemove: Set): void { - if (this.shouldBePresent === undefined) { - console.warn( - "ASSERTION VIOLATION! " + - `Cannot applyStateChange for label '${this.name}' ` + - "as its desired presence hasn't been defined. Returning early.", - ); - return; - } - - if (!this.present && this.shouldBePresent) { - if (!labelsToAdd.has(this.name)) { - console.log( - `Label.applyStateChange: '${this.name}' was not present and should be present. Scheduling addition.`, - ); - labelsToAdd.add(this.name); - } else { - console.log( - `Label.applyStateChange: '${this.name}' was not present and should be present. It is already scheduled for addition.`, - ); - } - } else if (this.present && !this.shouldBePresent) { - if (!labelsToRemove.has(this.name)) { - console.log( - `Label.applyStateChange: '${this.name}' was present and should not be present. Scheduling removal.`, - ); - labelsToRemove.add(this.name); - } else { - console.log( - `Label.applyStateChange: '${this.name}' was present and should not be present. It is already scheduled for removal.`, - ); - } - } else if (this.present === this.shouldBePresent) { - console.log( - `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"}. This is the desired state.`, - ); - } else { - console.warn( - "ASSERTION VIOLATION! " + - `Label.applyStateChange: '${this.name}' is ${this.present ? "present" : "not present"} while it should be ${this.shouldBePresent ? "present" : "not present"}. ` + - `At this point of execution this should not happen.`, - ); - } - } - - isEqualToOrPrefixOf(label: string): boolean { - return this.name.endsWith("*") ? label.startsWith(this.name.slice(0, -1)) : this.name === label; - } - - logString(): string { - return ( - `Label: name: ${this.name}, ` + - `present: ${this.present}, ` + - `shouldBePresent: ${this.shouldBePresent}. ` - ); - } -} - -export type FileListInfo = { - additions: string[]; - modifications: string[]; - deletions: string[]; - renames: { from: string; to: string }[]; - total: number; -}; - -export type PRContextOptions = { - fileList?: FileListInfo; - sourceBranch: string; - targetBranch: string; - sha: string; - repo: string; - owner: string; - prNumber: string; - isDraft: boolean; -}; - -// const toDiffResult = (results: string[]) => { -// return { -// additions: results, -// } as DiffResult; -// }; - -export class PRContext { - // we are starting with checking out before and after to different directories - // sourceDirectory corresponds to "after". EG the PR context is the "after" state of the PR. - // The "before" state is the git root directory without the changes aka targetDirectory - sourceDirectory: string; - targetDirectory: string; - sourceSpecModel?: SpecModel; - targetSpecModel?: SpecModel; - fileList?: FileListInfo; - sourceBranch: string; - targetBranch: string; - sha: string; - repo: string; - owner: string; - prNumber: string; - isDraft: boolean; - labelContext: LabelContext; - - constructor( - sourceDirectory: string, - targetDirectory: string, - labelContext: LabelContext, - options: PRContextOptions, - ) { - this.sourceDirectory = sourceDirectory; - this.targetDirectory = targetDirectory; - this.sourceSpecModel = new SpecModel(sourceDirectory); - this.targetSpecModel = new SpecModel(targetDirectory); - this.labelContext = labelContext; - this.sourceBranch = options.sourceBranch; - this.targetBranch = options.targetBranch; - this.sha = options.sha; - this.repo = options.repo; - this.prNumber = options.prNumber; - this.fileList = options.fileList; - this.isDraft = options.isDraft; - this.owner = options.owner; - } - - getChangedFiles(): string[] { - if (!this.fileList) { - return []; - } - - const changedFiles: string[] = (this.fileList.additions || []) - .concat(this.fileList.modifications || []) - .concat(this.fileList.deletions || []) - .concat(this.fileList.renames.map((r) => r.to) || []); - return changedFiles; - } - - // todo get rid of async here not necessary - // todo store the results - getTypeSpecDiffs(): DiffResult { - if (!this.fileList) { - return { - additions: [], - deletions: [], - changes: [], - }; - } - - const additions = this.fileList.additions.filter((file) => typespec(file)); - const deletions = this.fileList.deletions.filter((file) => typespec(file)); - const changes = this.fileList.modifications.filter((file) => typespec(file)); - - return { - additions, - deletions, - changes, - }; - } - - getSwaggerDiffs(): DiffResult { - if (!this.fileList) { - return { - additions: [], - deletions: [], - changes: [], - }; - } - - const additions = this.fileList.additions.filter((file) => swagger(file)); - const deletions = this.fileList.deletions.filter((file) => swagger(file)); - const changes = this.fileList.modifications.filter((file) => swagger(file)); - - return { - additions, - deletions, - changes, - }; - } - - getExampleDiffs(): DiffResult { - if (!this.fileList) { - return { - additions: [], - deletions: [], - changes: [], - }; - } - - const additions = this.fileList.additions.filter((file) => example(file)); - const deletions = this.fileList.deletions.filter((file) => example(file)); - const changes = this.fileList.modifications.filter((file) => example(file)); - - return { - additions, - deletions, - changes, - }; - } - - async getTagsFromReadme(readmePath: string): Promise { - const tags = await new Readme(readmePath).getTags(); - return [...tags.values()].map((tag) => tag.name); - } - - async getPossibleParentConfigurations(): Promise { - console.log("ENTER definition getPossibleParentConfigurations"); - const changedFiles = await this.getChangedFiles(); - console.log(`Detect changes in the PR:\n${JSON.stringify(changedFiles, null, 2)}`); - const readmes = changedFiles.filter((f) => readme(f)); - - const visitedFolder = new Set(); - changedFiles - .filter((f) => [".md", ".json", ".yaml", ".yml"].some((p) => f.endsWith(p))) - .forEach((f) => { - let dir = dirname(f); - if (visitedFolder.has(dir)) { - return; - } - while (specification(dir)) { - if (visitedFolder.has(dir)) { - break; - } - visitedFolder.add(dir); - const possibleReadme = join(dir, "readme.md"); - if (fs.existsSync(possibleReadme)) { - if (!readmes.includes(possibleReadme)) { - readmes.push(possibleReadme); - } - break; - } - dir = dirname(dir); - } - }); - console.log("RETURN definition getPossibleParentConfigurations"); - return readmes; - } - - getAllTags(readMeContent: string): string[] { - // todo: we should refactor this to use the spec model, but I haven't had the the time to explicitly - // diff what oad does does here, so I'm leaving it alone for now until I can build some strong unit tests around this. - const cmd = parseMarkdown(readMeContent); - const allTags = new amd.ReadMeManipulator( - { error: (_msg: string) => {} }, - new amd.ReadMeBuilder(), - ).getAllTags(cmd); - return [...allTags]; - } - - async getInputFiles(readMeContent: string, tag: string) { - // todo: we should refactor this to use spec model, but I haven't had time to isolate exactly what - // openapi-markdown is doing here, so I'm just going to use the same logic for now - const cmd = parseMarkdown(readMeContent); - return amd.getInputFilesForTag(cmd.markDown, tag); - } - - async getChangingTags(): Promise { - // we are retrieving all the readme changes, no matter if they're additions, deletions, etc - // Additionally, we're also retrieving all the readme files that may be affected by the changes in the PR, which means - // climbing up the directory tree until we find a readme.md file if necessary. - const allAffectedReadmes: string[] = await this.getPossibleParentConfigurations(); - console.log(`all affected readme are:`); - console.log(JSON.stringify(allAffectedReadmes, null, 2)); - const Diffs: TagDiff[] = []; - for (const readme of allAffectedReadmes) { - const oldReadme = join(this.targetDirectory, readme); - const newReadme = join(this.sourceDirectory, readme); - // As the readme may be not existing , need to check if it's existing. - // we are checking target and source individually, because the readme may not exist in the target branch - const oldTags: string[] = fs.existsSync(oldReadme) - ? [...(await new Readme(oldReadme).getTags()).keys()] - : []; - const newTags: string[] = fs.existsSync(newReadme) - ? [...(await new Readme(newReadme).getTags()).keys()] - : []; - const intersect = oldTags - .filter((t) => newTags.includes(t)) - .filter((tag) => tag !== "all-api-versions"); - const insertions = newTags.filter((t) => !oldTags.includes(t)); - const deletions = oldTags.filter((t) => !newTags.includes(t)); - const differences: TagConfigDiff[] = []; - - // todo: we need to ensure we get ALL effected swaggers by their relationships, not just the swagger files that are directly changed in the PR. - // right now I'm just going to filter to the ones that are directly changed in the PR. - // this is a temporary solution, we need to ensure we get all of the swagger files - // that are affected by the changes in the readme. SpecModel will be useful for this - // we want to get all of the swagger files that are affected by the changes in the readme. - // we can do that using the specmodel - // const allAffectedInputFiles = await this.getRealAffectedSwagger(readme) - // talk to Mike and ask him how we could get all affected swagger files from a readme path. - // I want to say that readme(readme).specModel.getAffectedSwaggerFiles will work? - const allAffectedInputFiles = await (await this.getChangedFiles()).filter((f) => swagger(f)); - console.log(`all affected swagger files in ${readme} are:`); - console.log(JSON.stringify(allAffectedInputFiles, null, 2)); - const getChangedInputFiles = async (tag: string) => { - const readmeContent = await fs.promises.readFile(newReadme, "utf-8"); - const inputFiles = await this.getInputFiles(readmeContent, tag); - if (inputFiles) { - const changedInputFiles = (inputFiles as string[]).filter((f) => - allAffectedInputFiles.some((a) => a.endsWith(f)), - ); - return changedInputFiles; - } - return []; - }; - const changes: string[] = []; - for (const tag of intersect) { - const tagDiff: TagConfigDiff = { name: tag }; - const changedInputFiles = await getChangedInputFiles(tag); - if (changedInputFiles.length) { - console.log("found changed input files under tag:" + tag); - tagDiff.changedInputFiles = changedInputFiles; - changes.push(tag); - } - } - Diffs.push({ readme, insertions, deletions, changes, differences }); - } - return Diffs; - } - - // this function is based upon LocalDirContext.getReadmeDiffs() and CommonPRContext.getReadmeDiffs() which are - // very different from each other (because why not). This implementation is based MOSTLY on CommonPRContext - async getReadmeDiffs(): Promise> { - // this gets all of the readme diffs - // const readmeAdditions = this.fileList?.additions.filter(file => readme(file)) || []; - // const readmeChanges = this.fileList?.modifications.filter(file => readme(file)) - // const readmeDeletions = this.fileList?.deletions.filter(file => readme(file)); - //const changedFiles: DiffFileResult | undefined = await this.localPRContext?.getChangingFiles(); - - const changedFiles = await this.fileList; - const tagDiffs = (await this.getChangingTags()) || []; - - const readmeTagDiffs = tagDiffs - ?.filter((tagDiff: TagDiff) => readme(tagDiff.readme)) - .map((tagDiff: TagDiff) => { - return { - readme: tagDiff.readme, - tags: { - changes: tagDiff.changes, - deletions: tagDiff.deletions, - additions: tagDiff.insertions, - }, - } as ReadmeTag; - }); - - const readmeTagDiffsInAddedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( - (readmeTag: ReadmeTag): boolean => - Boolean(changedFiles?.additions.includes(readmeTag.readme)), - ); - const readmeTagDiffsInDeletedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( - (readmeTag: ReadmeTag): boolean => - Boolean(changedFiles?.deletions.includes(readmeTag.readme)), - ); - const readmeTagDiffsInChangedReadmeFiles: ReadmeTag[] = readmeTagDiffs.filter( - (readmeTag: ReadmeTag): boolean => { - // The README file that contains the API version tags that have been diffed in given readmeTag (i.e. readmeTag.tags) - // has been modified. - const readmeModified = changedFiles?.modifications.includes(readmeTag.readme); - - // The README was not modified, added or deleted; just the specs belonging to the API version tags in the README were modified. - // In such case we assume the README is 'changed' in the sense the specs belonging to the API version tags in the README were modified. - // The README file itself wasn't modified. - // We assume here the README is present in the repository - otherwise it would show up as 'deleted' or would not - // appear as readmeTag.readme in any of the readmeTagDiffs. - const readmeUnchanged = - !changedFiles?.additions.includes(readmeTag.readme) && - !changedFiles?.deletions.includes(readmeTag.readme); - - return readmeModified || readmeUnchanged; - }, - ); - - const result = { - additions: readmeTagDiffsInAddedReadmeFiles, - deletions: readmeTagDiffsInDeletedReadmeFiles, - changes: readmeTagDiffsInChangedReadmeFiles, - }; - - console.log( - `RETURN definition CommonPRContext.getReadmeDiffs. ` + - `changedFiles: ${JSON.stringify(changedFiles)}, ` + - `tagDiffs: ${JSON.stringify(tagDiffs)}, ` + - `readmeTagDiffs: ${JSON.stringify(readmeTagDiffs)}, ` + - `this.readmeDiffs: ${JSON.stringify(result)}.`, - ); - - return result; - } -} diff --git a/eng/tools/summarize-impact/test/cli.test.ts b/eng/tools/summarize-impact/test/cli.test.ts index 2f1413932c8e..9e1fa1f18853 100644 --- a/eng/tools/summarize-impact/test/cli.test.ts +++ b/eng/tools/summarize-impact/test/cli.test.ts @@ -3,96 +3,105 @@ import { describe, it, expect } from "vitest"; //vi import path from "path"; import { getChangedFilesStatuses } from "@azure-tools/specs-shared/changed-files"; -import { PRContext, LabelContext } from "../src/types.js"; +import { PRContext } from "../src/PRContext.js"; +import { LabelContext } from "../src/labelling-types.js"; import { evaluateImpact } from "../src/impact.js"; describe("Check Changes", () => { - it.skipIf(!process.env.GITHUB_TOKEN || !process.env.INTEGRATION_TEST)("Integration test 35346", async () => { - const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); - const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); + it.skipIf(!process.env.GITHUB_TOKEN || !process.env.INTEGRATION_TEST)( + "Integration test 35346", + async () => { + const targetDirectory = path.join("/home/semick/repo/rest-s/35346", "before"); + const sourceDirectory = path.join("/home/semick/repo/rest-s/35346", "after"); - // Change to source directory and save original - const originalCwd = process.cwd(); - process.chdir(sourceDirectory); + // Change to source directory and save original + const originalCwd = process.cwd(); + process.chdir(sourceDirectory); - try { - const changedFileDetails = await getChangedFilesStatuses({ - cwd: sourceDirectory, - baseCommitish: "origin/main", - }); - const labelContext: LabelContext = { - present: new Set(), - toAdd: new Set(), - toRemove: new Set(), - }; + try { + const changedFileDetails = await getChangedFilesStatuses({ + cwd: sourceDirectory, + baseCommitish: "origin/main", + }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set(), + }; - const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { - sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", - sourceBranch: "dev/nandiniy/DTLTypeSpec", - targetBranch: "main", - repo: "azure-rest-api-specs", - prNumber: "35346", - owner: "Azure", - fileList: changedFileDetails, - isDraft: false, - }); + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "ad7c74cb27d2cf3ba83996aaea36b07caa4d16c8", + sourceBranch: "dev/nandiniy/DTLTypeSpec", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35346", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false, + }); - const result = await evaluateImpact(prContext, labelContext); + const result = await evaluateImpact(prContext, labelContext); - expect(result).toBeDefined(); - expect(result.typeSpecChanged).toBeTruthy(); - expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); - expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); - expect(changedFileDetails).toBeDefined(); - expect(changedFileDetails.total).toEqual(293); - } finally { - // Restore original directory - process.chdir(originalCwd); - } - }, 60000000); + expect(result).toBeDefined(); + expect(result.typeSpecChanged).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.labelContext.toAdd.has("SuppressionReviewRequired")).toBeTruthy(); + expect(changedFileDetails).toBeDefined(); + expect(changedFileDetails.total).toEqual(293); + } finally { + // Restore original directory + process.chdir(originalCwd); + } + }, + 60000000, + ); - it.skipIf(!process.env.GITHUB_TOKEN || !process.env.INTEGRATION_TEST)("Integration test 35982", async () => { - const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); - const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); + it.skipIf(!process.env.GITHUB_TOKEN || !process.env.INTEGRATION_TEST)( + "Integration test 35982", + async () => { + const targetDirectory = path.join("/home/semick/repo/rest-s/35982", "before"); + const sourceDirectory = path.join("/home/semick/repo/rest-s/35982", "after"); - // Change to source directory and save original - const originalCwd = process.cwd(); - process.chdir(sourceDirectory); + // Change to source directory and save original + const originalCwd = process.cwd(); + process.chdir(sourceDirectory); - try { - const changedFileDetails = await getChangedFilesStatuses({ - cwd: sourceDirectory, - baseCommitish: "origin/main", - }); - const labelContext: LabelContext = { - present: new Set(), - toAdd: new Set(), - toRemove: new Set(), - }; + try { + const changedFileDetails = await getChangedFilesStatuses({ + cwd: sourceDirectory, + baseCommitish: "origin/main", + }); + const labelContext: LabelContext = { + present: new Set(), + toAdd: new Set(), + toRemove: new Set(), + }; - const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { - sha: "2bd8350d465081401a0f4f03e633eca41f0991de", - sourceBranch: "features/users/deepika/cosmos-connectors-confluent", - targetBranch: "main", - repo: "azure-rest-api-specs", - prNumber: "35982", - owner: "Azure", - fileList: changedFileDetails, - isDraft: false, - }); + const prContext = new PRContext(sourceDirectory, targetDirectory, labelContext, { + sha: "2bd8350d465081401a0f4f03e633eca41f0991de", + sourceBranch: "features/users/deepika/cosmos-connectors-confluent", + targetBranch: "main", + repo: "azure-rest-api-specs", + prNumber: "35982", + owner: "Azure", + fileList: changedFileDetails, + isDraft: false, + }); - const result = await evaluateImpact(prContext, labelContext); - expect(result.isNewApiVersion).toBeTruthy(); - expect(result.labelContext.toAdd.has("TypeSpec")).toBeTruthy(); - expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); - expect(result.isNewApiVersion).toBeTruthy(); - expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); - expect(result.labelContext.toAdd.has("RPaaS")).toBeTruthy(); - expect(result.labelContext.toAdd.has("WaitForARMFeedback")).toBeTruthy(); - expect(result).toBeDefined(); - } finally { - // Restore original directory - process.chdir(originalCwd); - } - }, 60000000); + const result = await evaluateImpact(prContext, labelContext); + expect(result.isNewApiVersion).toBeTruthy(); + expect(result.labelContext.toAdd.has("TypeSpec")).toBeTruthy(); + expect(result.labelContext.toAdd.has("resource-manager")).toBeTruthy(); + expect(result.isNewApiVersion).toBeTruthy(); + expect(result.labelContext.toAdd.has("ARMReview")).toBeTruthy(); + expect(result.labelContext.toAdd.has("RPaaS")).toBeTruthy(); + expect(result.labelContext.toAdd.has("WaitForARMFeedback")).toBeTruthy(); + expect(result).toBeDefined(); + } finally { + // Restore original directory + process.chdir(originalCwd); + } + }, + 60000000, + ); }); From d49ce77ff45b0cc11c7f79fe0067aee56f147e8c Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 01:19:41 +0000 Subject: [PATCH 60/67] use correct targeting --- .github/workflows/src/summarize-checks/labelling.js | 2 +- .github/workflows/src/summarize-checks/summarize-checks.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index 775b3737dfde..3b3e4a5eb005 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -405,7 +405,7 @@ export const breakingChangesCheckType = { // #region Required Labels /** - * @param {import("./labelling.js").LabelContext} context + * @param {LabelContext} context * @param {string[]} existingLabels * @returns {void} */ diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index e97d0d168d33..998ec64d3fef 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -978,7 +978,7 @@ export async function getImpactAssessment(github, core, owner, repo, runId) { core.info(`Successfully downloaded job-summary artifact ID: ${jobSummaryArtifact.id}`); // Write zip buffer to temp file and extract JSON - const tmpZip = path.join(os.tmpdir(), `job-summary-${runId}.zip`); + const tmpZip = path.join(process.env.RUNNER_TEMP || os.tmpdir(), `job-summary-${runId}.zip`); // Convert ArrayBuffer to Buffer // Convert ArrayBuffer (download.data) to Node Buffer const arrayBuffer = /** @type {ArrayBuffer} */ (download.data); From c09b63eb602f54bf89fbfb5f27b0c9223be0b91a Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 01:42:03 +0000 Subject: [PATCH 61/67] fix label tests. needed to update thetest to reflect how update-labels is called --- .../src/summarize-checks/labelling.js | 2 +- .../workflows/test/summarize-checks.test.js | 43 +++---------------- 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index 3b3e4a5eb005..a38d47a013a0 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -418,7 +418,7 @@ export function processArmReviewLabels(context, existingLabels) { // if we are signed off, we should remove the "ARMChangesRequested" and "WaitForARMFeedback" labels if (containsAll(existingLabels, ["ARMSignedOff"])) { if (existingLabels.includes("ARMChangesRequested")) { - context.toAdd.add("ARMChangesRequested"); + context.toRemove.add("ARMChangesRequested"); } if (existingLabels.includes("WaitForARMFeedback")) { context.toRemove.add("WaitForARMFeedback"); diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index 5f6a56a50d38..aa95c54853eb 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -516,25 +516,16 @@ describe("Summarize Checks Tests", () => { describe("label add and remove", () => { const testCases = [ { - description: "labeled: ARMChangesRequested with WaitForARMFeedback present", - eventName: "labeled", - changedLabel: "ARMChangesRequested", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback"], }, { - description: "labeled: ARMChangesRequested without WaitForARMFeedback", - eventName: "labeled", - changedLabel: "ARMChangesRequested", existingLabels: ["other-label", "ARMChangesRequested"], expectedLabelsToAdd: [], expectedLabelsToRemove: [], }, { - description: "labeled: ARMSignedOff with both labels present", - eventName: "labeled", - changedLabel: "ARMSignedOff", existingLabels: [ "WaitForARMFeedback", "ARMSignedOff", @@ -545,57 +536,36 @@ describe("Summarize Checks Tests", () => { expectedLabelsToRemove: ["WaitForARMFeedback", "ARMChangesRequested"], }, { - description: "labeled: ARMSignedOff with only WaitForARMFeedback present", - eventName: "labeled", - changedLabel: "ARMSignedOff", existingLabels: ["WaitForARMFeedback", "ARMSignedOff", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback"], }, { - description: "labeled: ARMSignedOff with only ARMChangesRequested present", - eventName: "labeled", - changedLabel: "ARMSignedOff", existingLabels: ["ARMChangesRequested", "ARMSignedOff", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["ARMChangesRequested"], }, { - description: "labeled: ARMSignedOff with neither label present", - eventName: "labeled", - changedLabel: "ARMSignedOff", existingLabels: ["other-label", "ARMSignedOff"], expectedLabelsToAdd: [], expectedLabelsToRemove: [], }, { - description: "unlabeled: ARMChangesRequested with WaitForARMFeedback present (buggy case)", - eventName: "unlabeled", - changedLabel: "ARMChangesRequested", existingLabels: ["WaitForARMFeedback", "other-label"], expectedLabelsToAdd: [], expectedLabelsToRemove: [], }, { - description: "unlabeled: ARMChangesRequested without WaitForARMFeedback", - eventName: "unlabeled", - changedLabel: "ARMChangesRequested", existingLabels: ["other-label"], expectedLabelsToAdd: ["WaitForARMFeedback"], expectedLabelsToRemove: [], }, { - description: "labeled: other label should have no effect", - eventName: "labeled", - changedLabel: "SomeOtherLabel", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback"], }, { - description: "unlabeled: other label should have no effect", - eventName: "unlabeled", - changedLabel: "SomeOtherLabel", existingLabels: ["WaitForARMFeedback", "ARMChangesRequested"], expectedLabelsToAdd: [], expectedLabelsToRemove: ["WaitForARMFeedback"], @@ -605,21 +575,18 @@ describe("Summarize Checks Tests", () => { it.each(testCases)( "$description", async ({ - eventName, - changedLabel, existingLabels, expectedLabelsToAdd, expectedLabelsToRemove, }) => { - const [labelsToAdd, labelsToRemove] = await updateLabels( - eventName, - "main", + + const labelContext = await updateLabels( existingLabels, - changedLabel, + undefined, ); - expect(labelsToAdd.sort()).toEqual(expectedLabelsToAdd.sort()); - expect(labelsToRemove.sort()).toEqual(expectedLabelsToRemove.sort()); + expect([...labelContext.toAdd].sort()).toEqual(expectedLabelsToAdd.sort()); + expect([...labelContext.toRemove].sort()).toEqual(expectedLabelsToRemove.sort()); }, ); }); From 035f4f1c63ca73e7906aa2b841e605aec2f63441 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 01:50:49 +0000 Subject: [PATCH 62/67] moving our simple-git usage to a helper --- .github/shared/src/simple-git.js | 13 +++++++++++++ .github/shared/test/simple-git.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .github/shared/src/simple-git.js create mode 100644 .github/shared/test/simple-git.test.js diff --git a/.github/shared/src/simple-git.js b/.github/shared/src/simple-git.js new file mode 100644 index 000000000000..df4ffacbd06a --- /dev/null +++ b/.github/shared/src/simple-git.js @@ -0,0 +1,13 @@ +import { simpleGit } from "simple-git"; +import { resolve } from "path"; + +/** + * + * @param {string} inputPath + * @returns {Promise} + */ +export async function getRootFolder(inputPath) { + // expecting users to handle the case where inputPath is not a git repo + const gitRoot = await simpleGit(inputPath).revparse("--show-toplevel"); + return resolve(gitRoot.trim()); +} \ No newline at end of file diff --git a/.github/shared/test/simple-git.test.js b/.github/shared/test/simple-git.test.js new file mode 100644 index 000000000000..16357e7fced8 --- /dev/null +++ b/.github/shared/test/simple-git.test.js @@ -0,0 +1,24 @@ +import path from 'path'; +import os from 'os'; +import { mkdtemp, rm } from 'fs/promises'; +import { getRootFolder } from '../src/simple-git.js'; +import { describe, expect, it } from "vitest"; + +describe('getRootFolder', () => { + it('resolves to repo root from a nested folder', async () => { + // Use __dirname (this test file directory) as input to getRootFolder + const testDir = __dirname; + const calculatedRoot = await getRootFolder(testDir); + // Expect the repository root 3 levels up from this test file + const expectedRoot = path.resolve(path.join(__dirname, '..', '..', '..')); + expect(calculatedRoot).toBe(expectedRoot); + }); + + it('throws when directory is not a git repository', async () => { + // Use async mkdtemp from fs/promises + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'non-git-')); + await expect(getRootFolder(tempDir)).rejects.toThrow(); + // Cleanup the temp directory + await rm(tempDir, { recursive: true, force: true }); + }); +}); \ No newline at end of file From c2133210dd74099c650a4accd5bbc514b8be77d7 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 01:53:47 +0000 Subject: [PATCH 63/67] moving simple-git interaction to helper --- .github/shared/package.json | 1 + eng/tools/summarize-impact/src/cli.ts | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/shared/package.json b/.github/shared/package.json index 5c94d416ddfb..b20ab5106766 100644 --- a/.github/shared/package.json +++ b/.github/shared/package.json @@ -19,6 +19,7 @@ "./spec-model": "./src/spec-model.js", "./swagger": "./src/swagger.js", "./tag": "./src/tag.js", + "./simple-git": "./src/simple-git.js", "./test/examples": "./test/examples.js" }, "bin": { diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index 303c12f3f4f1..f7a03da226af 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -7,13 +7,13 @@ import { setOutput } from "@azure-tools/specs-shared/error-reporting"; import { resolve, join } from "path"; import fs from "fs"; import { parseArgs, ParseArgsConfig } from "node:util"; -import { simpleGit } from "simple-git"; import { LabelContext } from "./labelling-types.js"; import { PRContext } from "./PRContext.js"; +import { getRootFolder } from "@azure-tools/specs-shared/simple-git" -export async function getRootFolder(inputPath: string): Promise { +export async function getRoot(inputPath: string): Promise { try { - const gitRoot = await simpleGit(inputPath).revparse("--show-toplevel"); + const gitRoot = await getRootFolder(inputPath); return resolve(gitRoot.trim()); } catch (error) { console.error( @@ -90,8 +90,8 @@ export async function main() { // todo: refactor these opts const sourceDirectory = opts.sourceDirectory as string; const targetDirectory = opts.targetDirectory as string; - const sourceGitRoot = await getRootFolder(sourceDirectory); - const targetGitRoot = await getRootFolder(targetDirectory); + const sourceGitRoot = await getRoot(sourceDirectory); + const targetGitRoot = await getRoot(targetDirectory); const fileList = await getChangedFilesStatuses({ cwd: sourceGitRoot }); const sha = opts.sha as string; const sourceBranch = opts.sourceBranch as string; From 944407ae4dc3ae16e1b25fd71a59b7cacfe519e7 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 01:55:35 +0000 Subject: [PATCH 64/67] cleanup comments --- .github/shared/test/simple-git.test.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/shared/test/simple-git.test.js b/.github/shared/test/simple-git.test.js index 16357e7fced8..0af54b5bbdde 100644 --- a/.github/shared/test/simple-git.test.js +++ b/.github/shared/test/simple-git.test.js @@ -6,19 +6,15 @@ import { describe, expect, it } from "vitest"; describe('getRootFolder', () => { it('resolves to repo root from a nested folder', async () => { - // Use __dirname (this test file directory) as input to getRootFolder const testDir = __dirname; const calculatedRoot = await getRootFolder(testDir); - // Expect the repository root 3 levels up from this test file const expectedRoot = path.resolve(path.join(__dirname, '..', '..', '..')); expect(calculatedRoot).toBe(expectedRoot); }); it('throws when directory is not a git repository', async () => { - // Use async mkdtemp from fs/promises const tempDir = await mkdtemp(path.join(os.tmpdir(), 'non-git-')); await expect(getRootFolder(tempDir)).rejects.toThrow(); - // Cleanup the temp directory await rm(tempDir, { recursive: true, force: true }); }); }); \ No newline at end of file From 456cee8ba67a73ee57f0deb3376030dfc673ba00 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 01:57:54 +0000 Subject: [PATCH 65/67] apply formatting fixes --- .github/shared/src/simple-git.js | 8 ++++---- .github/shared/test/simple-git.test.js | 20 +++++++++---------- .../workflows/test/summarize-checks.test.js | 12 ++--------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/.github/shared/src/simple-git.js b/.github/shared/src/simple-git.js index df4ffacbd06a..cdf0d7bf5c9f 100644 --- a/.github/shared/src/simple-git.js +++ b/.github/shared/src/simple-git.js @@ -7,7 +7,7 @@ import { resolve } from "path"; * @returns {Promise} */ export async function getRootFolder(inputPath) { - // expecting users to handle the case where inputPath is not a git repo - const gitRoot = await simpleGit(inputPath).revparse("--show-toplevel"); - return resolve(gitRoot.trim()); -} \ No newline at end of file + // expecting users to handle the case where inputPath is not a git repo + const gitRoot = await simpleGit(inputPath).revparse("--show-toplevel"); + return resolve(gitRoot.trim()); +} diff --git a/.github/shared/test/simple-git.test.js b/.github/shared/test/simple-git.test.js index 0af54b5bbdde..3fbd308430a7 100644 --- a/.github/shared/test/simple-git.test.js +++ b/.github/shared/test/simple-git.test.js @@ -1,20 +1,20 @@ -import path from 'path'; -import os from 'os'; -import { mkdtemp, rm } from 'fs/promises'; -import { getRootFolder } from '../src/simple-git.js'; +import path from "path"; +import os from "os"; +import { mkdtemp, rm } from "fs/promises"; +import { getRootFolder } from "../src/simple-git.js"; import { describe, expect, it } from "vitest"; -describe('getRootFolder', () => { - it('resolves to repo root from a nested folder', async () => { +describe("getRootFolder", () => { + it("resolves to repo root from a nested folder", async () => { const testDir = __dirname; const calculatedRoot = await getRootFolder(testDir); - const expectedRoot = path.resolve(path.join(__dirname, '..', '..', '..')); + const expectedRoot = path.resolve(path.join(__dirname, "..", "..", "..")); expect(calculatedRoot).toBe(expectedRoot); }); - it('throws when directory is not a git repository', async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), 'non-git-')); + it("throws when directory is not a git repository", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "non-git-")); await expect(getRootFolder(tempDir)).rejects.toThrow(); await rm(tempDir, { recursive: true, force: true }); }); -}); \ No newline at end of file +}); diff --git a/.github/workflows/test/summarize-checks.test.js b/.github/workflows/test/summarize-checks.test.js index aa95c54853eb..6d3208b26423 100644 --- a/.github/workflows/test/summarize-checks.test.js +++ b/.github/workflows/test/summarize-checks.test.js @@ -574,16 +574,8 @@ describe("Summarize Checks Tests", () => { it.each(testCases)( "$description", - async ({ - existingLabels, - expectedLabelsToAdd, - expectedLabelsToRemove, - }) => { - - const labelContext = await updateLabels( - existingLabels, - undefined, - ); + async ({ existingLabels, expectedLabelsToAdd, expectedLabelsToRemove }) => { + const labelContext = await updateLabels(existingLabels, undefined); expect([...labelContext.toAdd].sort()).toEqual(expectedLabelsToAdd.sort()); expect([...labelContext.toRemove].sort()).toEqual(expectedLabelsToRemove.sort()); From c6c81112f2f18e04584c37cb35f202d6987ed411 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 04:15:50 +0000 Subject: [PATCH 66/67] swap over to async + use execFile from shared --- .github/workflows/src/summarize-checks/labelling.js | 4 +++- .../src/summarize-checks/summarize-checks.js | 11 ++++++----- eng/tools/summarize-impact/src/cli.ts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/src/summarize-checks/labelling.js b/.github/workflows/src/summarize-checks/labelling.js index a38d47a013a0..aab8a2203a38 100644 --- a/.github/workflows/src/summarize-checks/labelling.js +++ b/.github/workflows/src/summarize-checks/labelling.js @@ -234,7 +234,9 @@ export class Label { `Cannot applyStateChange for label '${this.name}' ` + "as its desired presence hasn't been defined. Returning early.", ); - return; + throw new Error( + `Label '${this.name}' has not been properly initialized with shouldBePresent before being applied.`, + ); } if (!this.present && this.shouldBePresent) { diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 998ec64d3fef..5b3fdb5b7948 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -23,6 +23,7 @@ import { extractInputs } from "../context.js"; // eslint-disable-next-line no-unused-vars import { commentOrUpdate } from "../comment.js"; import { PER_PAGE_MAX } from "../github.js"; +import { execFile } from "../../../shared/src/exec.js"; import { verRevApproval, brChRevApproval, @@ -41,7 +42,7 @@ import { typeSpecRequirementDataPlaneTsg, } from "./tsgs.js"; -import fs from "fs"; +import fs from "fs/promises"; import os from "os"; import path from "path"; import { execSync } from "child_process"; @@ -983,14 +984,14 @@ export async function getImpactAssessment(github, core, owner, repo, runId) { // Convert ArrayBuffer (download.data) to Node Buffer const arrayBuffer = /** @type {ArrayBuffer} */ (download.data); const zipBuffer = Buffer.from(new Uint8Array(arrayBuffer)); - fs.writeFileSync(tmpZip, zipBuffer); + await fs.writeFile(tmpZip, zipBuffer); // Extract JSON content from zip archive - const jsonContent = execSync(`unzip -p ${tmpZip}`); - fs.unlinkSync(tmpZip); + const { stdout: jsonContent } = await execFile("unzip", ["-p", tmpZip]); + await fs.unlink(tmpZip); /** @type {import("./labelling.js").ImpactAssessment} */ // todo: we need to zod this to ensure the structure is correct, however we do not have zod installed at time of run - const impact = JSON.parse(jsonContent.toString("utf8")); + const impact = JSON.parse(jsonContent); return impact; } catch (/** @type {any} */ error) { core.error(`Failed to download job summary artifact: ${error.message}`); diff --git a/eng/tools/summarize-impact/src/cli.ts b/eng/tools/summarize-impact/src/cli.ts index f7a03da226af..75641b3b6093 100644 --- a/eng/tools/summarize-impact/src/cli.ts +++ b/eng/tools/summarize-impact/src/cli.ts @@ -9,7 +9,7 @@ import fs from "fs"; import { parseArgs, ParseArgsConfig } from "node:util"; import { LabelContext } from "./labelling-types.js"; import { PRContext } from "./PRContext.js"; -import { getRootFolder } from "@azure-tools/specs-shared/simple-git" +import { getRootFolder } from "@azure-tools/specs-shared/simple-git"; export async function getRoot(inputPath: string): Promise { try { From 27232c331efa5db63d839611217d497746d96596 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 22 Jul 2025 17:37:07 +0000 Subject: [PATCH 67/67] fix linting issue to unblock merge --- .github/workflows/src/summarize-checks/summarize-checks.js | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/src/summarize-checks/summarize-checks.js b/.github/workflows/src/summarize-checks/summarize-checks.js index 5b3fdb5b7948..3504f81aa507 100644 --- a/.github/workflows/src/summarize-checks/summarize-checks.js +++ b/.github/workflows/src/summarize-checks/summarize-checks.js @@ -45,7 +45,6 @@ import { import fs from "fs/promises"; import os from "os"; import path from "path"; -import { execSync } from "child_process"; /** * @typedef {Object} CheckMetadata