From e9dc8167126b1d5ddf14189ea501da1f867cb824 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Wed, 12 Nov 2025 11:54:14 -0600 Subject: [PATCH 01/36] Add logic to auto-signoff open-api-specs with trivial changes --- ...ec.yaml => arm-auto-signoff-analysis.yaml} | 43 +- .github/workflows/arm-auto-signoff.yaml | 36 +- .github/workflows/src/arm-auto-signoff.js | 201 +++++---- .../workflows/src/trivial-changes-check.js | 379 ++++++++++++++++ .../test/trivial-changes-check.test.js | 407 ++++++++++++++++++ documentation/trivial-changes-auto-signoff.md | 115 +++++ 6 files changed, 1082 insertions(+), 99 deletions(-) rename .github/workflows/{arm-incremental-typespec.yaml => arm-auto-signoff-analysis.yaml} (54%) create mode 100644 .github/workflows/src/trivial-changes-check.js create mode 100644 .github/workflows/test/trivial-changes-check.test.js create mode 100644 documentation/trivial-changes-auto-signoff.md diff --git a/.github/workflows/arm-incremental-typespec.yaml b/.github/workflows/arm-auto-signoff-analysis.yaml similarity index 54% rename from .github/workflows/arm-incremental-typespec.yaml rename to .github/workflows/arm-auto-signoff-analysis.yaml index b84d1017ff43..546012e73bfa 100644 --- a/.github/workflows/arm-incremental-typespec.yaml +++ b/.github/workflows/arm-auto-signoff-analysis.yaml @@ -1,4 +1,4 @@ -name: ARM Incremental TypeSpec +name: ARM Auto-SignOff Analysis on: pull_request: @@ -20,8 +20,8 @@ permissions: contents: read jobs: - arm-incremental-typespec: - name: ARM Incremental TypeSpec + arm-auto-signoff-analysis: + name: ARM Auto-SignOff Analysis runs-on: ubuntu-24.04 @@ -56,8 +56,39 @@ jobs: await import('${{ github.workspace }}/.github/workflows/src/arm-incremental-typespec.js'); return await incrementalTypeSpec({ github, context, core }); - - name: Upload artifact with results + # Check for trivial changes that can also qualify for auto sign-off + - id: trivial-changes + name: Trivial Changes Check + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + const { default: checkTrivialChanges } = + await import('${{ github.workspace }}/.github/workflows/src/trivial-changes-check.js'); + return await checkTrivialChanges({ github, context, core }); + + # Create combined artifact with all results + - id: combined-results + name: Combine Results + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + const incrementalResult = '${{ steps.incremental-typespec.outputs.result }}' === 'true'; + const trivialChangesResult = JSON.parse('${{ steps.trivial-changes.outputs.result }}'); + + const combined = { + incrementalTypeSpec: incrementalResult, + trivialChanges: trivialChangesResult, + qualifiesForAutoSignOff: incrementalResult || trivialChangesResult.anyTrivialChanges + }; + + core.info(`Combined result: ${JSON.stringify(combined)}`); + return JSON.stringify(combined); + + # Upload new combined artifact with all information + - name: Upload artifact - Combined Results uses: ./.github/actions/add-empty-artifact with: - name: "incremental-typespec" - value: ${{ steps.incremental-typespec.outputs.result }} + name: "arm-analysis-results" + value: ${{ steps.combined-results.outputs.result }} diff --git a/.github/workflows/arm-auto-signoff.yaml b/.github/workflows/arm-auto-signoff.yaml index d4f46fdb1eb5..d412ced01d92 100644 --- a/.github/workflows/arm-auto-signoff.yaml +++ b/.github/workflows/arm-auto-signoff.yaml @@ -12,7 +12,7 @@ on: - unlabeled workflow_run: workflows: - ["ARM Incremental TypeSpec", "Swagger Avocado - Set Status", "Swagger LintDiff - Set Status"] + ["ARM Auto-SignOff Analysis", "Swagger Avocado - Set Status", "Swagger LintDiff - Set Status"] types: [completed] permissions: @@ -40,7 +40,8 @@ jobs: (github.event.action == 'labeled' || github.event.action == 'unlabeled') && (github.event.label.name == 'Approved-Suppression' || - github.event.label.name == 'ARMAutoSignedOff' || + github.event.label.name == 'ARMAutoSignedOff-IncrementalTSP' || + github.event.label.name == 'ARMAutoSignedOff-Trivial' || github.event.label.name == 'ARMReview' || github.event.label.name == 'ARMSignedOff' || github.event.label.name == 'NotReadyForARMReview' || @@ -77,25 +78,28 @@ jobs: await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff.js'); return await getLabelAction({ github, context, core }); - - if: | - fromJson(steps.get-label-action.outputs.result).labelAction == 'add' || - fromJson(steps.get-label-action.outputs.result).labelAction == 'remove' - name: Upload artifact with results + # Add/remove specific auto sign-off labels based on analysis results + # Only upload label artifacts if we have autoSignOffLabels defined (not undefined) + - if: fromJson(steps.get-label-action.outputs.result).autoSignOffLabels != null + name: Upload artifact for ARMSignedOff label uses: ./.github/actions/add-label-artifact with: - name: "ARMAutoSignedOff" - # Convert "add/remove" to "true/false" - value: "${{ fromJson(steps.get-label-action.outputs.result).labelAction == 'add' }}" + name: "ARMSignedOff" + value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignOffLabels && (contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-IncrementalTSP') || contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-Trivial')) }}" - - if: | - fromJson(steps.get-label-action.outputs.result).labelAction == 'add' || - fromJson(steps.get-label-action.outputs.result).labelAction == 'remove' - name: Upload artifact with results + - if: fromJson(steps.get-label-action.outputs.result).autoSignOffLabels != null + name: Upload artifact for ARMAutoSignedOff-IncrementalTSP label uses: ./.github/actions/add-label-artifact with: - name: "ARMSignedOff" - # Convert "add/remove" to "true/false" - value: "${{ fromJson(steps.get-label-action.outputs.result).labelAction == 'add' }}" + name: "ARMAutoSignedOff-IncrementalTSP" + value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignOffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-IncrementalTSP') }}" + + - if: fromJson(steps.get-label-action.outputs.result).autoSignOffLabels != null + name: Upload artifact for ARMAutoSignedOff-Trivial label + uses: ./.github/actions/add-label-artifact + with: + name: "ARMAutoSignedOff-Trivial" + value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignOffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-Trivial') }}" # Required for consumers to identify the head SHA associated with this workflow run. # Output can be trusted, because it was uploaded from a workflow that is trusted, diff --git a/.github/workflows/src/arm-auto-signoff.js b/.github/workflows/src/arm-auto-signoff.js index cd32552b86cb..02c1d7ebc232 100644 --- a/.github/workflows/src/arm-auto-signoff.js +++ b/.github/workflows/src/arm-auto-signoff.js @@ -2,13 +2,12 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../shared/src/github.js"; import { equals } from "../../shared/src/set.js"; import { byDate, invert } from "../../shared/src/sort.js"; import { extractInputs } from "./context.js"; -import { LabelAction } from "./label.js"; // TODO: Add tests /* v8 ignore start */ /** * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments - * @returns {Promise<{labelAction: LabelAction, issueNumber: number}>} + * @returns {Promise<{headSha: string, issueNumber: number, autoSignOffLabels?: string[]}>} */ export default async function getLabelAction({ github, context, core }) { const { owner, repo, issue_number, head_sha } = await extractInputs(github, context, core); @@ -32,27 +31,9 @@ export default async function getLabelAction({ github, context, core }) { * @param {string} params.head_sha * @param {(import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api & { paginate: import("@octokit/plugin-paginate-rest").PaginateInterface; })} params.github * @param {typeof import("@actions/core")} params.core - * @returns {Promise<{labelAction: LabelAction, headSha: string, issueNumber: number}>} + * @returns {Promise<{headSha: string, issueNumber: number, autoSignOffLabels?: string[]}>} */ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, github, core }) { - const labelActions = { - [LabelAction.None]: { - labelAction: LabelAction.None, - headSha: head_sha, - issueNumber: issue_number, - }, - [LabelAction.Add]: { - labelAction: LabelAction.Add, - headSha: head_sha, - issueNumber: issue_number, - }, - [LabelAction.Remove]: { - labelAction: LabelAction.Remove, - headSha: head_sha, - issueNumber: issue_number, - }, - }; - // TODO: Try to extract labels from context (when available) to avoid unnecessary API call // permissions: { issues: read, pull-requests: read } const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { @@ -63,13 +44,15 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, }); const labelNames = labels.map((label) => label.name); - // Only remove labels "ARMSignedOff" and "ARMAutoSignedOff", if "ARMAutoSignedOff" is currently present. - // Necessary to prevent removing "ARMSignedOff" if added by a human reviewer. - const removeAction = labelNames.includes("ARMAutoSignedOff") - ? labelActions[LabelAction.Remove] - : labelActions[LabelAction.None]; + // Check if any auto sign-off labels are currently present + // Only proceed with auto sign-off logic if auto labels exist or we're about to add them + const hasAutoSignedOffLabels = labelNames.some(name => + name === "ARMAutoSignedOff-Trivial" || + name === "ARMAutoSignedOff-IncrementalTSP" + ); core.info(`Labels: ${labelNames}`); + core.info(`Has auto signed-off labels: ${hasAutoSignedOffLabels}`); // permissions: { actions: read } const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { @@ -85,54 +68,21 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, core.info(`- ${wf.name}: ${wf.conclusion || wf.status}`); }); - const wfName = "ARM Incremental TypeSpec"; - const incrementalTspRuns = workflowRuns - .filter((wf) => wf.name == wfName) - // Sort by "updated_at" descending - .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + // Check ARM Incremental TypeSpec workflow (which now includes trivial changes) + const armAnalysisResult = await checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core); - if (incrementalTspRuns.length == 0) { - core.info( - `Found no runs for workflow '${wfName}'. Assuming workflow trigger was skipped, which should be treated equal to "completed false".`, - ); - return removeAction; - } else { - // Sorted by "updated_at" descending, so most recent run is at index 0 - const run = incrementalTspRuns[0]; - - if (run.status == "completed") { - if (run.conclusion != "success") { - core.info(`Run for workflow '${wfName}' did not succeed: '${run.conclusion}'`); - return removeAction; - } - - // permissions: { actions: read } - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: PER_PAGE_MAX, - }); - const artifactNames = artifacts.map((a) => a.name); - - core.info(`artifactNames: ${JSON.stringify(artifactNames)}`); - - if (artifactNames.includes("incremental-typespec=false")) { - core.info("Spec is not an incremental change to an existing TypeSpec RP"); - return removeAction; - } else if (artifactNames.includes("incremental-typespec=true")) { - core.info("Spec is an incremental change to an existing TypeSpec RP"); - // Continue checking other requirements - } else { - // If workflow succeeded, it should have one workflow or the other - throw `Workflow artifacts did not contain 'incremental-typespec': ${JSON.stringify(artifactNames)}`; - } - } else { - core.info(`Workflow '${wfName}' is still in-progress: status='${run.status}'`); - return labelActions[LabelAction.None]; - } + // If workflow indicates auto-signoff should not be applied + if (!armAnalysisResult.shouldAutoSign) { + return { + headSha: head_sha, + issueNumber: issue_number, + autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + }; } + // Store the labels for auto sign-off + const autoSignOffLabels = armAnalysisResult.labelsToAdd || []; + const allLabelsMatch = labelNames.includes("ARMReview") && !labelNames.includes("NotReadyForARMReview") && @@ -141,7 +91,11 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, if (!allLabelsMatch) { core.info("Labels do not meet requirement for auto-signoff"); - return removeAction; + return { + headSha: head_sha, + issueNumber: issue_number, + autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + }; } // permissions: { statuses: read } @@ -166,7 +120,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, for (const statusName of requiredStatusNames) { // The "statuses" array may contain multiple statuses with the same "context" (aka "name"), - // but different states and update times. We only care about the latest. + // but different states and update times. We only care about the latest. const matchingStatuses = statuses .filter((status) => status.context.toLowerCase() === statusName.toLowerCase()) .sort(invert(byDate((status) => status.updated_at))); @@ -182,7 +136,11 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, matchingStatus.state === CommitStatusState.FAILURE) ) { core.info(`Status '${matchingStatus}' did not succeed`); - return removeAction; + return { + headSha: head_sha, + issueNumber: issue_number, + autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + }; } if (matchingStatus) { @@ -198,10 +156,99 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, requiredStatuses.every((status) => status.state === CommitStatusState.SUCCESS) ) { core.info("All requirements met for auto-signoff"); - return labelActions[LabelAction.Add]; + return { + headSha: head_sha, + issueNumber: issue_number, + autoSignOffLabels: autoSignOffLabels, + }; } - // If any statuses are missing or pending, no-op to prevent frequent remove/add label as checks re-run + // If any statuses are missing or pending, return empty labels only if we had auto labels before + // This prevents removing manually-added ARMSignedOff labels core.info("One or more statuses are still pending"); - return labelActions[LabelAction.None]; + return { + headSha: head_sha, + issueNumber: issue_number, + autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // undefined means don't touch labels + }; +} + +/** + * Check ARM Analysis workflow results (combines incremental TypeSpec and trivial changes) + * @param {Array} workflowRuns - Array of workflow runs + * @param {Object} github - GitHub client + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {Object} core - Core logger + * @returns {Promise<{shouldAutoSign: boolean, reason: string, labelsToAdd?: string[]}>} + */ +async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) { + const wfName = "ARM Auto-SignOff Analysis"; + const armAnalysisRuns = workflowRuns + .filter((wf) => wf.name == wfName) + // Sort by "updated_at" descending + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + + if (armAnalysisRuns.length == 0) { + core.info( + `Found no runs for workflow '${wfName}'. Assuming workflow trigger was skipped, which should be treated equal to "completed false".`, + ); + return { shouldAutoSign: false, reason: "No ARM analysis workflow runs found" }; + } + + // Sorted by "updated_at" descending, so most recent run is at index 0 + const run = armAnalysisRuns[0]; + + if (run.status != "completed") { + core.info(`Workflow '${wfName}' is still in-progress: status='${run.status}'`); + return { shouldAutoSign: false, reason: "ARM analysis workflow still in progress" }; + } + + if (run.conclusion != "success") { + core.info(`Run for workflow '${wfName}' did not succeed: '${run.conclusion}'`); + return { shouldAutoSign: false, reason: "ARM analysis workflow failed" }; + } + + // permissions: { actions: read } + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: PER_PAGE_MAX, + }); + const artifactNames = artifacts.map((a) => a.name); + + core.info(`${wfName} artifactNames: ${JSON.stringify(artifactNames)}`); + + // check for new combined artifact + const combinedArtifact = artifactNames.find(name => name.startsWith("arm-analysis-results=")); + if (combinedArtifact) { + try { + const resultsJson = combinedArtifact.substring("arm-analysis-results=".length); + const results = JSON.parse(resultsJson); + + core.info(`Combined ARM analysis results: ${JSON.stringify(results)}`); + + if (results.qualifiesForAutoSignOff) { + const labelsToAdd = []; + + if (results.incrementalTypeSpec) { + labelsToAdd.push("ARMAutoSignedOff-IncrementalTSP"); + } + if (results.trivialChanges?.anyTrivialChanges) { + labelsToAdd.push("ARMAutoSignedOff-Trivial"); + } + + const reason = labelsToAdd.join(", "); + core.info(`PR qualifies for auto sign-off: ${reason}`); + return { shouldAutoSign: true, reason: reason, labelsToAdd: labelsToAdd }; + } else { + core.info("PR does not qualify for auto sign-off based on ARM analysis"); + return { shouldAutoSign: false, reason: "No qualifying changes detected" }; + } + } catch (error) { + core.warning(`Failed to parse combined ARM analysis results: ${error.message}`); + // Fall back to legacy artifact checking + } + } } diff --git a/.github/workflows/src/trivial-changes-check.js b/.github/workflows/src/trivial-changes-check.js new file mode 100644 index 000000000000..d19f2aa96a3e --- /dev/null +++ b/.github/workflows/src/trivial-changes-check.js @@ -0,0 +1,379 @@ +// For now, treat all paths as posix, since this is the format returned from git commands +import debug from "debug"; +import { simpleGit } from "simple-git"; +import { + getChangedFiles, + getChangedFilesStatuses, + json, + example, +} from "../../shared/src/changed-files.js"; +import { CoreLogger } from "./core-logger.js"; + +// Enable simple-git debug logging to improve console output +debug.enable("simple-git"); + +/** + * Non-functional JSON property keys that are safe to change without affecting API behavior + */ +const NON_FUNCTIONAL_PROPERTIES = new Set([ + 'description', + 'title', + 'summary', + 'x-ms-summary', + 'x-ms-description', + 'externalDocs', + 'x-ms-examples', + 'x-example', + 'example', + 'examples', + 'x-ms-client-name', + 'x-ms-parameter-name', + 'x-ms-enum', + 'x-ms-discriminator-value', + 'x-ms-client-flatten', + 'x-ms-azure-resource', + 'x-ms-mutability', + 'x-ms-secret', + 'x-ms-parameter-location', + 'x-ms-skip-url-encoding', + 'x-ms-long-running-operation', + 'x-ms-long-running-operation-options', + 'x-ms-pageable', + 'x-ms-odata', + 'tags', + 'x-ms-api-annotation' +]); + +/** + * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments + * @returns {Promise<{documentationOnlyChanges: boolean, examplesOnlyChanges: boolean, nonFunctionalChanges: boolean, anyTrivialChanges: boolean}>} + */ +export default async function checkTrivialChanges({ core }) { + const options = { + cwd: process.env.GITHUB_WORKSPACE, + logger: new CoreLogger(core), + }; + + const changedFiles = await getChangedFiles(options); + const changedFilesStatuses = await getChangedFilesStatuses(options); + const git = simpleGit(options.cwd); + + if (changedFiles.length === 0) { + core.info("No changes detected in PR"); + return { + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }; + } + + // Check for documentation-only changes (.md files) + const documentationFiles = changedFiles.filter(file => + file.toLowerCase().endsWith('.md') + ); + const documentationOnlyChanges = documentationFiles.length > 0 && + documentationFiles.length === changedFiles.length; + + core.info(`Documentation files changed: ${documentationFiles.length}`); + core.info(`Documentation-only changes: ${documentationOnlyChanges}`); + + // Check for examples-only changes + const exampleFiles = changedFiles.filter(example); + const examplesOnlyChanges = exampleFiles.length > 0 && + exampleFiles.length === changedFiles.length; + + core.info(`Example files changed: ${exampleFiles.length}`); + core.info(`Examples-only changes: ${examplesOnlyChanges}`); + + // Check what types of non-JSON files we have (used for multiple checks) + // Note: Example files are JSON files, so they won't be in this list + const nonJsonFiles = changedFiles.filter(file => !json(file)); + const nonJsonFilesAreTrivial = nonJsonFiles.every(file => + file.toLowerCase().endsWith('.md') + ); + + // Check for non-functional JSON changes + let nonFunctionalChanges = false; + const jsonFiles = changedFiles.filter(json).filter(file => !example(file)); + + if (jsonFiles.length > 0) { + core.info(`Analyzing ${jsonFiles.length} JSON files for non-functional changes...`); + + // Check if all JSON changes are non-functional + let allJsonChangesNonFunctional = true; + + for (const file of jsonFiles) { + core.info(`Analyzing file: ${file}`); + + try { + const isNonFunctional = await analyzeJsonFileChanges(file, git, core); + if (!isNonFunctional) { + core.info(`File ${file} contains functional changes`); + allJsonChangesNonFunctional = false; + break; + } else { + core.info(`File ${file} contains only non-functional changes`); + } + } catch (error) { + core.warning(`Failed to analyze ${file}: ${error.message}`); + allJsonChangesNonFunctional = false; + break; + } + } + + // Non-functional changes if: + // 1. All non-example JSON files have only non-functional changes, AND + // 2. Any remaining files are documentation (.md files) + // Note: Example JSON files are already considered trivial by design + nonFunctionalChanges = allJsonChangesNonFunctional && nonJsonFilesAreTrivial; + } + + core.info(`Non-functional changes: ${nonFunctionalChanges}`); + + // Calculate if ANY trivial changes exist + // This should be true if ALL changed files are trivial types: + // - Documentation files (.md) + // - Example files (/examples/*.json) + // - JSON files with only non-functional changes + const trivialFileTypes = documentationFiles.length + exampleFiles.length; + const functionalJsonFiles = jsonFiles.filter(async (file) => { + try { + return !(await analyzeJsonFileChanges(file, git, core)); + } catch { + return true; // Treat errors as functional changes + } + }); + + // For anyTrivialChanges, we need to check if ALL files are trivial + // This is different from the individual "only" checks + let anyTrivialChanges; + if (documentationOnlyChanges || examplesOnlyChanges) { + // Pure documentation or pure examples + anyTrivialChanges = true; + } else if (nonFunctionalChanges) { + // JSON files with only non-functional changes (and possibly docs) + anyTrivialChanges = true; + } else if (jsonFiles.length === 0 && trivialFileTypes === changedFiles.length) { + // Mixed documentation + examples (no JSON files to analyze) + anyTrivialChanges = true; + } else { + // Some files are functional changes + anyTrivialChanges = false; + } + + core.info(`Any trivial changes: ${anyTrivialChanges}`); + + const result = { + documentationOnlyChanges, + examplesOnlyChanges, + nonFunctionalChanges, + anyTrivialChanges, + }; + + core.info(`Final result: ${JSON.stringify(result)}`); + + return result; +} + +/** + * Analyzes changes in a JSON file to determine if they are non-functional + * @param {string} file - The file path + * @param {import('simple-git').SimpleGit} git - Git instance + * @param {typeof import("@actions/core")} core - Core logger + * @returns {Promise} - True if changes are non-functional + */ +async function analyzeJsonFileChanges(file, git, core) { + let baseContent; + let headContent; + + try { + baseContent = await git.show([`HEAD^:${file}`]); + } catch (e) { + if (e instanceof Error && e.message.includes("does not exist")) { + // New file - consider it functional change unless it's pure metadata + core.info(`File ${file} is new`); + try { + headContent = await git.show([`HEAD:${file}`]); + return isNewFileNonFunctional(headContent, core); + } catch { + return false; + } + } else { + throw e; + } + } + + try { + headContent = await git.show([`HEAD:${file}`]); + } catch (e) { + if (e instanceof Error && e.message.includes("does not exist")) { + // File deleted - consider it functional change + core.info(`File ${file} was deleted`); + return false; + } else { + throw e; + } + } + + let baseJson, headJson; + + try { + baseJson = JSON.parse(baseContent); + headJson = JSON.parse(headContent); + } catch (error) { + core.warning(`Failed to parse JSON for ${file}: ${error.message}`); + return false; + } + + return analyzeJsonDifferences(baseJson, headJson, "", core); +} + +/** + * Analyzes if a new JSON file contains only non-functional content + * @param {string} content - JSON content + * @param {typeof import("@actions/core")} core - Core logger + * @returns {boolean} - True if non-functional + */ +function isNewFileNonFunctional(content, core) { + try { + const jsonObj = JSON.parse(content); + // For new files, we're more conservative - only consider it non-functional + // if it's clearly metadata-only (like a new example file) + return false; // Conservative approach for new files + } catch { + return false; + } +} + +/** + * Recursively analyzes differences between two JSON objects + * @param {any} baseObj - Base object + * @param {any} headObj - Head object + * @param {string} path - Current path in the object + * @param {typeof import("@actions/core")} core - Core logger + * @returns {boolean} - True if all differences are non-functional + */ +function analyzeJsonDifferences(baseObj, headObj, path, core) { + // If types differ, it's a functional change + if (typeof baseObj !== typeof headObj) { + core.info(`Type change at ${path || 'root'}: ${typeof baseObj} -> ${typeof headObj}`); + return false; + } + + // Handle null values + if (baseObj === null || headObj === null) { + if (baseObj !== headObj) { + core.info(`Null value change at ${path || 'root'}`); + return false; + } + return true; + } + + // Handle arrays + if (Array.isArray(baseObj) && Array.isArray(headObj)) { + // For arrays, we need to be careful about order and additions/removals + if (baseObj.length !== headObj.length) { + // Check if the change is just adding/removing non-functional properties + const arrayPath = path || 'root'; + // For now, treat any array length change as functional unless we can prove otherwise + core.info(`Array length change at ${arrayPath}: ${baseObj.length} -> ${headObj.length}`); + + // Special case: if arrays contain only strings and changes are in non-functional properties like tags + if (isNonFunctionalArrayChange(baseObj, headObj, path)) { + return true; + } + return false; + } + + // Check each element + for (let i = 0; i < baseObj.length; i++) { + if (!analyzeJsonDifferences(baseObj[i], headObj[i], `${path}[${i}]`, core)) { + return false; + } + } + return true; + } + + // Handle objects + if (typeof baseObj === 'object' && typeof headObj === 'object') { + const baseKeys = new Set(Object.keys(baseObj)); + const headKeys = new Set(Object.keys(headObj)); + + // Check for added or removed keys + for (const key of baseKeys) { + if (!headKeys.has(key)) { + const fullPath = path ? `${path}.${key}` : key; + if (!NON_FUNCTIONAL_PROPERTIES.has(key)) { + core.info(`Property removed at ${fullPath} (functional)`); + return false; + } + core.info(`Property removed at ${fullPath} (non-functional)`); + } + } + + for (const key of headKeys) { + if (!baseKeys.has(key)) { + const fullPath = path ? `${path}.${key}` : key; + if (!NON_FUNCTIONAL_PROPERTIES.has(key)) { + core.info(`Property added at ${fullPath} (functional)`); + return false; + } + core.info(`Property added at ${fullPath} (non-functional)`); + } + } + + // Check changed properties + for (const key of baseKeys) { + if (headKeys.has(key)) { + const fullPath = path ? `${path}.${key}` : key; + if (!analyzeJsonDifferences(baseObj[key], headObj[key], fullPath, core)) { + // If the change is in a non-functional property, it's still non-functional + if (NON_FUNCTIONAL_PROPERTIES.has(key)) { + core.info(`Property changed at ${fullPath} (non-functional)`); + continue; + } + return false; + } + } + } + + return true; + } + + // Handle primitive values + if (baseObj !== headObj) { + const currentProperty = path.split('.').pop() || path.split('[')[0]; + if (NON_FUNCTIONAL_PROPERTIES.has(currentProperty)) { + core.info(`Value changed at ${path || 'root'} (non-functional): "${baseObj}" -> "${headObj}"`); + return true; + } + core.info(`Value changed at ${path || 'root'} (functional): "${baseObj}" -> "${headObj}"`); + return false; + } + + return true; +} + +/** + * Checks if an array change is non-functional (e.g., tags, examples) + * @param {any[]} baseArray - Base array + * @param {any[]} headArray - Head array + * @param {string} path - Current path + * @returns {boolean} - True if non-functional + */ +function isNonFunctionalArrayChange(baseArray, headArray, path) { + const currentProperty = path.split('.').pop() || path; + + // Tags array changes are typically non-functional + if (currentProperty === 'tags') { + return true; + } + + // Examples array changes are typically non-functional + if (currentProperty === 'examples' || currentProperty === 'x-ms-examples') { + return true; + } + + return false; +} \ No newline at end of file diff --git a/.github/workflows/test/trivial-changes-check.test.js b/.github/workflows/test/trivial-changes-check.test.js new file mode 100644 index 000000000000..b3fc94cb1c48 --- /dev/null +++ b/.github/workflows/test/trivial-changes-check.test.js @@ -0,0 +1,407 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("simple-git", () => ({ + simpleGit: vi.fn().mockReturnValue({ + show: vi.fn().mockResolvedValue(""), + }), +})); + +import * as simpleGit from "simple-git"; +import * as changedFiles from "../../shared/src/changed-files.js"; +import checkTrivialChangesImpl from "../src/trivial-changes-check.js"; +import { createMockCore } from "./mocks.js"; + +const core = createMockCore(); + +/** + * @param {unknown} asyncFunctionArgs + */ +function checkTrivialChanges(asyncFunctionArgs) { + return checkTrivialChangesImpl( + /** @type {import("@actions/github-script").AsyncFunctionArguments} */ (asyncFunctionArgs), + ); +} + +describe("checkTrivialChanges", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("throws if inputs null", async () => { + await expect(checkTrivialChanges({})).rejects.toThrow(); + }); + + it("returns all false if no changed files", async () => { + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([]); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }); + }); + + it("returns documentation only changes for .md files", async () => { + const mdFiles = [ + "specification/someservice/README.md", + "documentation/some-doc.md", + "docs/api-guide.md" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mdFiles); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: true, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: true, + }); + }); + + it("returns examples only changes for example files", async () => { + const exampleFiles = [ + "specification/someservice/examples/Get.json", + "specification/anotherservice/examples/Create.json" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(exampleFiles); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: true, + nonFunctionalChanges: false, + anyTrivialChanges: true, + }); + }); + + it("returns non-functional changes for JSON files with only non-functional properties", async () => { + const jsonFiles = [ + "specification/someservice/stable/2021-01-01/service.json" + ]; + + const oldJson = JSON.stringify({ + info: { title: "Service API", version: "1.0" }, + paths: { "/test": { get: { summary: "Test endpoint" } } } + }); + + const newJson = JSON.stringify({ + info: { + title: "Service API", + version: "1.0", + description: "New description" // Non-functional change + }, + paths: { "/test": { get: { summary: "Test endpoint" } } } + }); + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + + const showSpy = vi.mocked(simpleGit.simpleGit().show) + .mockImplementation((args) => { + if (Array.isArray(args)) { + const ref = args[0]; + if (ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } else if (ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: true, + anyTrivialChanges: true, + }); + + expect(showSpy).toHaveBeenCalledWith([`HEAD^:${jsonFiles[0]}`]); + expect(showSpy).toHaveBeenCalledWith([`HEAD:${jsonFiles[0]}`]); + }); + + it("returns false for functional changes in JSON files", async () => { + const jsonFiles = [ + "specification/someservice/stable/2021-01-01/service.json" + ]; + + const oldJson = JSON.stringify({ + info: { title: "Service API", version: "1.0" }, + paths: { "/test": { get: { summary: "Test endpoint" } } } + }); + + const newJson = JSON.stringify({ + info: { title: "Service API", version: "1.0" }, + paths: { + "/test": { get: { summary: "Test endpoint" } }, + "/new": { post: { summary: "New endpoint" } } // Functional change + } + }); + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + + vi.mocked(simpleGit.simpleGit().show) + .mockImplementation((args) => { + if (Array.isArray(args)) { + const ref = args[0]; + if (ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } else if (ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }); + }); + + it("handles mixed trivial and non-trivial changes", async () => { + const mixedFiles = [ + "specification/someservice/README.md", // Documentation (trivial) + "specification/someservice/examples/Get.json", // Examples (trivial) + "specification/someservice/stable/2021-01-01/service.json" // Functional change (non-trivial) + ]; + + const oldJson = JSON.stringify({ + paths: { "/test": { get: { summary: "Test endpoint" } } } + }); + + const newJson = JSON.stringify({ + paths: { + "/test": { get: { summary: "Test endpoint" } }, + "/new": { post: { summary: "New endpoint" } } // Functional change + } + }); + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedFiles); + + vi.mocked(simpleGit.simpleGit().show) + .mockImplementation((args) => { + if (Array.isArray(args)) { + const ref = args[0]; + if (ref.includes("service.json")) { + if (ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } else if (ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + } + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges({ core }); + + // Should detect functional changes, so overall not trivial + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }); + }); + + it("handles JSON parsing errors gracefully", async () => { + const jsonFiles = [ + "specification/someservice/stable/2021-01-01/service.json" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + + vi.mocked(simpleGit.simpleGit().show) + .mockImplementation((args) => { + if (Array.isArray(args)) { + const ref = args[0]; + if (ref.startsWith("HEAD^:")) { + return Promise.resolve("{ invalid json"); + } else if (ref.startsWith("HEAD:")) { + return Promise.resolve("{ also invalid }"); + } + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges({ core }); + + // Should treat JSON parsing errors as non-trivial changes + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }); + }); + + it("handles git show errors gracefully", async () => { + const jsonFiles = [ + "specification/someservice/stable/2021-01-01/service.json" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + + vi.mocked(simpleGit.simpleGit().show) + .mockRejectedValue(new Error("Git operation failed")); + + const result = await checkTrivialChanges({ core }); + + // Should treat git errors as non-trivial changes + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }); + }); + + it("handles nested non-functional property changes", async () => { + const jsonFiles = [ + "specification/someservice/stable/2021-01-01/service.json" + ]; + + const oldJson = JSON.stringify({ + info: { title: "Service API", version: "1.0" }, + paths: { + "/test": { + get: { + summary: "Test endpoint", + operationId: "Test_Get" + } + } + } + }); + + const newJson = JSON.stringify({ + info: { + title: "Service API", + version: "1.0", + description: "API description" // Non-functional change + }, + paths: { + "/test": { + get: { + summary: "Updated test endpoint", // Non-functional change + description: "Gets test data", // Non-functional change + operationId: "Test_Get" + } + } + } + }); + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + + vi.mocked(simpleGit.simpleGit().show) + .mockImplementation((args) => { + if (Array.isArray(args)) { + const ref = args[0]; + if (ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } else if (ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: true, + anyTrivialChanges: true, + }); + }); + + it("detects functional changes in nested properties", async () => { + const jsonFiles = [ + "specification/someservice/stable/2021-01-01/service.json" + ]; + + const oldJson = JSON.stringify({ + info: { title: "Service API", version: "1.0" }, + paths: { + "/test": { + get: { + parameters: [ + { name: "id", in: "path", required: true, type: "string" } + ] + } + } + } + }); + + const newJson = JSON.stringify({ + info: { title: "Service API", version: "1.0" }, + paths: { + "/test": { + get: { + parameters: [ + { name: "id", in: "path", required: true, type: "string" }, + { name: "filter", in: "query", required: false, type: "string" } // Functional change + ] + } + } + } + }); + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + + vi.mocked(simpleGit.simpleGit().show) + .mockImplementation((args) => { + if (Array.isArray(args)) { + const ref = args[0]; + if (ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } else if (ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, + examplesOnlyChanges: false, + nonFunctionalChanges: false, + anyTrivialChanges: false, + }); + }); + + it("handles mixed documentation and examples correctly", async () => { + const mixedTrivialFiles = [ + "specification/someservice/README.md", + "docs/changelog.md", + "specification/someservice/examples/Get.json", + "specification/anotherservice/examples/Create.json" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedTrivialFiles); + + const result = await checkTrivialChanges({ core }); + + expect(result).toEqual({ + documentationOnlyChanges: false, // Not ONLY documentation + examplesOnlyChanges: false, // Not ONLY examples + nonFunctionalChanges: false, // No JSON analysis needed + anyTrivialChanges: true, // But it IS all trivial + }); + }); +}); \ No newline at end of file diff --git a/documentation/trivial-changes-auto-signoff.md b/documentation/trivial-changes-auto-signoff.md new file mode 100644 index 000000000000..16a3ab4d4073 --- /dev/null +++ b/documentation/trivial-changes-auto-signoff.md @@ -0,0 +1,115 @@ +# Integrated Trivial Changes Auto Sign-off + +This document describes the integrated trivial changes detection functionality within the ARM Incremental TypeSpec +workflow that extends auto sign-off capabilities. + +## Overview + +The ARM Incremental TypeSpec workflow now includes built-in detection for three types of trivial changes that can +qualify for auto sign-off: + +1. **Documentation Only Changes** - PRs that only modify `.md` files +2. **Examples Only Changes** - PRs that only modify files in `/examples/` folders +3. **Non-functional Changes** - PRs that only modify non-functional properties in JSON files + +## Workflow Details + +### ARM Incremental TypeSpec Workflow (`arm-incremental-typespec.yaml`) + +This workflow has been enhanced to run both the original ARM incremental TypeSpec analysis and the new trivial +changes detection. It produces: + +1. **Legacy artifact** - `incremental-typespec=true/false` (for backward compatibility) +2. **Combined artifact** - `arm-analysis-results` containing a JSON structure: + + ```json + { + "incrementalTypeSpec": true/false, + "trivialChanges": { + "documentationOnlyChanges": true/false, + "examplesOnlyChanges": true/false, + "nonFunctionalChanges": true/false, + "anyTrivialChanges": true/false + }, + "qualifiesForAutoSignOff": true/false + } + ``` + +### Non-functional Properties + +The following JSON properties are considered non-functional (safe to change without affecting API behavior): + +- `description`, `title`, `summary` +- `x-ms-summary`, `x-ms-description` +- `externalDocs` +- `x-ms-examples`, `x-example`, `example`, `examples` +- `x-ms-client-name`, `x-ms-parameter-name` +- `x-ms-enum`, `x-ms-discriminator-value` +- `x-ms-client-flatten`, `x-ms-azure-resource` +- `x-ms-mutability`, `x-ms-secret` +- `x-ms-parameter-location`, `x-ms-skip-url-encoding` +- `x-ms-long-running-operation`, `x-ms-long-running-operation-options` +- `x-ms-pageable`, `x-ms-odata` +- `tags`, `x-ms-api-annotation` + +### ARM Auto Sign-off Integration + +The ARM auto sign-off workflow consumes the combined artifact from the ARM Incremental TypeSpec workflow. +Auto sign-off is applied if: + +1. The PR contains incremental TypeSpec changes, OR +2. The PR contains any qualifying trivial changes + +All other requirements must still be met (proper labels, passing status checks, etc.). + +### Labels + +When trivial changes are detected and auto sign-off is applied, the following labels may be added: + +- `DocumentationOnlyChanges` - For documentation-only PRs +- `ExamplesOnlyChanges` - For examples-only PRs +- `NonFunctionalChanges` - For non-functional JSON changes + +## Files Changed + +### Modified Files + +- `.github/workflows/arm-incremental-typespec.yaml` - Enhanced with trivial changes detection +- `.github/workflows/src/arm-auto-signoff.js` - Updated to handle combined analysis results +- `.github/workflows/arm-auto-signoff.yaml` - Updated label handling for new artifact format + +### New Files + +- `.github/workflows/src/trivial-changes-check.js` - Trivial changes detection logic +- `.github/workflows/test/trivial-changes-check.test.js` - Basic tests + +## Configuration + +No additional configuration is required. The workflow automatically runs on all PRs and detects trivial changes +based on the predefined criteria. + +## Testing + +To test the functionality: + +1. Create a PR with only documentation changes (`.md` files) +2. Create a PR with only example changes (files in `/examples/` folders) +3. Create a PR with only non-functional JSON property changes +4. Verify that the appropriate labels are applied and auto sign-off occurs + +## Limitations + +- The non-functional changes detection is conservative and may not catch all possible non-functional changes +- New JSON files are currently treated as functional changes for safety +- Array changes in JSON are treated conservatively and may be flagged as functional even if they're not +- The workflow requires all other auto sign-off requirements to be met (proper labels, passing checks, etc.) + +## Future Improvements + +Potential enhancements: + +1. More sophisticated JSON diff analysis +2. Support for additional file types +3. Configurable non-functional property lists +4. Better handling of complex JSON structure changes +5. Integration with OpenAPI/Swagger semantic analysis tools From b9618b0712b75bca7e99fe411d2cecec7defe80f Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 4 Dec 2025 00:15:05 -0600 Subject: [PATCH 02/36] Updates based on comments, Refactor trivial check --- .github/shared/src/changed-files.js | 9 + .github/shared/src/pr-changes.js | 86 +++++ .../workflows/arm-auto-signoff-analysis.yaml | 94 ------ .github/workflows/arm-auto-signoff-code.yaml | 66 ++++ ...noff.yaml => arm-auto-signoff-status.yaml} | 10 +- .../arm-auto-signoff/arm-auto-signoff-code.js | 48 +++ .../arm-auto-signoff-status.js} | 16 +- .../incremental-typespec.js} | 0 .../trivial-changes-check.js | 294 ++++++++++-------- .../workflows/test/arm-auto-signoff.test.js | 2 +- .../test/trivial-changes-check.test.js | 124 +++++--- 11 files changed, 461 insertions(+), 288 deletions(-) create mode 100644 .github/shared/src/pr-changes.js delete mode 100644 .github/workflows/arm-auto-signoff-analysis.yaml create mode 100644 .github/workflows/arm-auto-signoff-code.yaml rename .github/workflows/{arm-auto-signoff.yaml => arm-auto-signoff-status.yaml} (95%) create mode 100644 .github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js rename .github/workflows/src/{arm-auto-signoff.js => arm-auto-signoff/arm-auto-signoff-status.js} (92%) rename .github/workflows/src/{arm-incremental-typespec.js => arm-auto-signoff/incremental-typespec.js} (100%) rename .github/workflows/src/{ => arm-auto-signoff}/trivial-changes-check.js (50%) diff --git a/.github/shared/src/changed-files.js b/.github/shared/src/changed-files.js index b6be750a4746..1df8dea55c63 100644 --- a/.github/shared/src/changed-files.js +++ b/.github/shared/src/changed-files.js @@ -179,6 +179,15 @@ export function readme(file) { return typeof file === "string" && file.toLowerCase().endsWith("readme.md"); } +/** + * @param {string} [file] + * @returns {boolean} + */ +export function markdown(file) { + // Extension ".md" with any case is a valid markdown file + return typeof file === "string" && file.toLowerCase().endsWith(".md"); +} + /** * @param {string} [file] * @returns {boolean} diff --git a/.github/shared/src/pr-changes.js b/.github/shared/src/pr-changes.js new file mode 100644 index 000000000000..d2ed37061fa3 --- /dev/null +++ b/.github/shared/src/pr-changes.js @@ -0,0 +1,86 @@ +/** + * Represents the types of changes present in a pull request. + * + * All properties are boolean flags that indicate presence of a change type. + * An empty PR would have all properties set to false. + * + * @typedef {Object} PullRequestChanges + * @property {boolean} documentation - True if PR contains documentation (.md) file changes + * @property {boolean} examples - True if PR contains example file changes (/examples/*.json) + * @property {boolean} functional - True if PR contains functional spec changes (API-impacting) + * @property {boolean} nonFunctional - True if PR contains non-functional spec changes (metadata only) + * @property {boolean} other - True if PR contains other file types (config, scripts, etc.) + */ + +/** + * Creates a PullRequestChanges object with all flags set to false + * @returns {PullRequestChanges} + */ +export function createEmptyPullRequestChanges() { + return { + documentation: false, + examples: false, + functional: false, + nonFunctional: false, + other: false, + }; +} + +/** + * Checks if a PR qualifies as trivial based on its changes + * A PR is trivial if it contains only: + * - Documentation changes + * - Example changes + * - Non-functional spec changes + * And does NOT contain: + * - Functional spec changes + * - Other file types + * + * @param {PullRequestChanges} changes - The PR changes object + * @returns {boolean} - True if PR is trivial + */ +export function isTrivialPullRequest(changes) { + // Trivial if no functional changes and no other files + // Must have at least one of: documentation, examples, or nonFunctional + const hasNoBlockingChanges = !changes.functional && !changes.other; + const hasTrivialChanges = changes.documentation || changes.examples || changes.nonFunctional; + + return hasNoBlockingChanges && hasTrivialChanges; +} + +/** + * Checks if a PR contains only documentation changes + * @param {PullRequestChanges} changes - The PR changes object + * @returns {boolean} - True if only documentation changed + */ +export function isDocumentationOnly(changes) { + return changes.documentation && + !changes.examples && + !changes.functional && + !changes.nonFunctional && + !changes.other; +} + +/** + * Checks if a PR contains only example changes + * @param {PullRequestChanges} changes - The PR changes object + * @returns {boolean} - True if only examples changed + */ +export function isExamplesOnly(changes) { + return !changes.documentation && + changes.examples && + !changes.functional && + !changes.nonFunctional && + !changes.other; +} + +/** + * Checks if a PR contains only non-functional spec changes (with optional docs/examples) + * @param {PullRequestChanges} changes - The PR changes object + * @returns {boolean} - True if only non-functional changes (+ optional docs/examples) + */ +export function isNonFunctionalOnly(changes) { + return changes.nonFunctional && + !changes.functional && + !changes.other; +} diff --git a/.github/workflows/arm-auto-signoff-analysis.yaml b/.github/workflows/arm-auto-signoff-analysis.yaml deleted file mode 100644 index 546012e73bfa..000000000000 --- a/.github/workflows/arm-auto-signoff-analysis.yaml +++ /dev/null @@ -1,94 +0,0 @@ -name: ARM Auto-SignOff Analysis - -on: - pull_request: - types: - # default - - opened - - synchronize - - reopened - # re-run if base branch is changed, since previous merge commit may generate incorrect diff - - edited - paths: - # Always trigger on changes to WF itself - - ".github/**" - # Only trigger if PR includes at least one changed RM file, since this is only way WF can return "true" - # All consumers of workflow must treat "skipped" as equal to "completed false" - - "**/resource-manager/**" - -permissions: - contents: read - -jobs: - arm-auto-signoff-analysis: - name: ARM Auto-SignOff Analysis - - runs-on: ubuntu-24.04 - - steps: - - uses: actions/checkout@v4 - with: - # Required to detect changed files in PR - fetch-depth: 2 - # Check only needs to view contents of changed files as a string, so can use - # "git show" on specific files instead of cloning whole repo - sparse-checkout: | - .github - - - name: Setup Node 20 and install deps - uses: ./.github/actions/setup-node-install-deps - with: - # actions/github-script@v7 uses Node 20 - node-version: 20.x - # "--no-audit": improves performance - # "--omit dev": not needed at runtime, improves performance - install-command: "npm ci --no-audit --omit dev" - working-directory: ./.github - - # Output is "true" if PR contains only incremental changes to an existing TypeSpec RP - - id: incremental-typespec - name: ARM Incremental TypeSpec - uses: actions/github-script@v7 - with: - result-encoding: string - script: | - const { default: incrementalTypeSpec } = - await import('${{ github.workspace }}/.github/workflows/src/arm-incremental-typespec.js'); - return await incrementalTypeSpec({ github, context, core }); - - # Check for trivial changes that can also qualify for auto sign-off - - id: trivial-changes - name: Trivial Changes Check - uses: actions/github-script@v7 - with: - result-encoding: string - script: | - const { default: checkTrivialChanges } = - await import('${{ github.workspace }}/.github/workflows/src/trivial-changes-check.js'); - return await checkTrivialChanges({ github, context, core }); - - # Create combined artifact with all results - - id: combined-results - name: Combine Results - uses: actions/github-script@v7 - with: - result-encoding: string - script: | - const incrementalResult = '${{ steps.incremental-typespec.outputs.result }}' === 'true'; - const trivialChangesResult = JSON.parse('${{ steps.trivial-changes.outputs.result }}'); - - const combined = { - incrementalTypeSpec: incrementalResult, - trivialChanges: trivialChangesResult, - qualifiesForAutoSignOff: incrementalResult || trivialChangesResult.anyTrivialChanges - }; - - core.info(`Combined result: ${JSON.stringify(combined)}`); - return JSON.stringify(combined); - - # Upload new combined artifact with all information - - name: Upload artifact - Combined Results - uses: ./.github/actions/add-empty-artifact - with: - name: "arm-analysis-results" - value: ${{ steps.combined-results.outputs.result }} diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml new file mode 100644 index 000000000000..074b64b8aef4 --- /dev/null +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -0,0 +1,66 @@ +name: ARM Auto-SignOff - Analyze Code + +on: + pull_request: + types: + # default + - opened + - synchronize + - reopened + # re-run if base branch is changed, since previous merge commit may generate incorrect diff + - edited + paths: + # Always trigger on changes to WF itself + - ".github/**" + # Only trigger if PR includes at least one changed RM file, since this is only way WF can return "true" + # All consumers of workflow must treat "skipped" as equal to "completed false" + - "**/resource-manager/**" + +permissions: + contents: read + +jobs: + arm-auto-signoff-code: + name: ARM Auto-SignOff - Analyze Code + + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + with: + # Fetch full PR history including base branch for proper diff comparison + fetch-depth: 0 + # Fetch base branch to compare against + ref: ${{ github.event.pull_request.head.sha }} + # Check only needs to view contents of changed files as a string, so can use + # "git show" on specific files instead of cloning whole repo + sparse-checkout: | + .github + + - name: Setup Node 20 and install deps + uses: ./.github/actions/setup-node-install-deps + with: + # actions/github-script@v7 uses Node 20 + node-version: 20.x + # "--no-audit": improves performance + # "--omit dev": not needed at runtime, improves performance + install-command: "npm ci --no-audit --omit dev" + working-directory: ./.github + + # Run ARM Auto-SignOff Code analysis + - id: arm-auto-signoff-code + name: ARM Auto-SignOff Code + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + const { default: armAutoSignOffCode } = + await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js'); + return await armAutoSignOffCode({ github, context, core }); + + # Upload artifact with all results + - name: Upload artifact - ARM Auto-SignOff Code Results + uses: ./.github/actions/add-empty-artifact + with: + name: "arm-auto-signoff-code-results" + value: ${{ steps.arm-auto-signoff-code.outputs.result }} diff --git a/.github/workflows/arm-auto-signoff.yaml b/.github/workflows/arm-auto-signoff-status.yaml similarity index 95% rename from .github/workflows/arm-auto-signoff.yaml rename to .github/workflows/arm-auto-signoff-status.yaml index d412ced01d92..d75b3c95599b 100644 --- a/.github/workflows/arm-auto-signoff.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -1,4 +1,4 @@ -name: ARM Auto SignOff +name: ARM Auto SignOff - Set Status on: # Must run on pull_request_target instead of pull_request, since the latter cannot trigger on @@ -12,7 +12,7 @@ on: - unlabeled workflow_run: workflows: - ["ARM Auto-SignOff Analysis", "Swagger Avocado - Set Status", "Swagger LintDiff - Set Status"] + ["ARM Auto-SignOff- Analyze Code", "Swagger Avocado - Set Status", "Swagger LintDiff - Set Status"] types: [completed] permissions: @@ -30,7 +30,7 @@ permissions: jobs: arm-auto-signoff: - name: ARM Auto SignOff + name: ARM Auto SignOff - Set Status # workflow_run - already filtered by triggers above # pull_request_target:labeled - filter to only the input and output labels @@ -70,12 +70,12 @@ jobs: # issueNumber: number # } - id: get-label-action - name: ARM Auto SignOff + name: ARM Auto SignOff uses: actions/github-script@v7 with: script: | const { default: getLabelAction } = - await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff.js'); + await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js'); return await getLabelAction({ github, context, core }); # Add/remove specific auto sign-off labels based on analysis results diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js new file mode 100644 index 000000000000..1a109c80f2cd --- /dev/null +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -0,0 +1,48 @@ +import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; + +/** + * Main entry point for ARM Auto-SignOff Code workflow + * + * This module orchestrates all checks to determine if a PR qualifies for auto sign-off. + * It combines results from: + * - Incremental TypeSpec check + * - Trivial changes check + * + * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments + * @returns {Promise} JSON string containing combined results + */ +export default async function armAutoSignOffCode({ github, context, core }) { + const { default: incrementalTypeSpec } = await import('./incremental-typespec.js'); + const { default: checkTrivialChanges } = await import('./trivial-changes-check.js'); + + core.info('Starting ARM Auto-SignOff Code analysis...'); + + // Run incremental TypeSpec check + core.startGroup('Checking for incremental TypeSpec changes'); + const incrementalTypeSpecResult = await incrementalTypeSpec({ github, context, core }); + core.info(`Incremental TypeSpec result: ${incrementalTypeSpecResult}`); + core.endGroup(); + + // Run trivial changes check + core.startGroup('Checking for trivial changes'); + const trivialChangesResultString = await checkTrivialChanges({ github, context, core }); + const trivialChanges = JSON.parse(trivialChangesResultString); + core.info(`Trivial changes result: ${JSON.stringify(trivialChanges)}`); + core.endGroup(); + + // Determine if PR qualifies for auto sign-off + const isTrivial = isTrivialPullRequest(trivialChanges); + const qualifiesForAutoSignOff = incrementalTypeSpecResult || isTrivial; + + // Combine results + const combined = { + incrementalTypeSpec: incrementalTypeSpecResult, + trivialChanges: trivialChanges, + qualifiesForAutoSignOff: qualifiesForAutoSignOff + }; + + core.info(`Combined result: ${JSON.stringify(combined, null, 2)}`); + core.info(`Qualifies for auto sign-off: ${combined.qualifiesForAutoSignOff}`); + + return JSON.stringify(combined); +} diff --git a/.github/workflows/src/arm-auto-signoff.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js similarity index 92% rename from .github/workflows/src/arm-auto-signoff.js rename to .github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 02c1d7ebc232..fcf848ba4b2c 100644 --- a/.github/workflows/src/arm-auto-signoff.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -2,6 +2,7 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../shared/src/github.js"; import { equals } from "../../shared/src/set.js"; import { byDate, invert } from "../../shared/src/sort.js"; import { extractInputs } from "./context.js"; +import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; // TODO: Add tests /* v8 ignore start */ @@ -183,7 +184,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, * @returns {Promise<{shouldAutoSign: boolean, reason: string, labelsToAdd?: string[]}>} */ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) { - const wfName = "ARM Auto-SignOff Analysis"; + const wfName = "ARM Auto-SignOff - Analyze Code"; const armAnalysisRuns = workflowRuns .filter((wf) => wf.name == wfName) // Sort by "updated_at" descending @@ -193,7 +194,7 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) core.info( `Found no runs for workflow '${wfName}'. Assuming workflow trigger was skipped, which should be treated equal to "completed false".`, ); - return { shouldAutoSign: false, reason: "No ARM analysis workflow runs found" }; + return { shouldAutoSign: false, reason: `No '${wfName}' workflow runs found` }; } // Sorted by "updated_at" descending, so most recent run is at index 0 @@ -201,12 +202,12 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) if (run.status != "completed") { core.info(`Workflow '${wfName}' is still in-progress: status='${run.status}'`); - return { shouldAutoSign: false, reason: "ARM analysis workflow still in progress" }; + return { shouldAutoSign: false, reason: `'${wfName}' workflow still in progress` }; } if (run.conclusion != "success") { core.info(`Run for workflow '${wfName}' did not succeed: '${run.conclusion}'`); - return { shouldAutoSign: false, reason: "ARM analysis workflow failed" }; + return { shouldAutoSign: false, reason: `'${wfName}' workflow failed` }; } // permissions: { actions: read } @@ -221,10 +222,10 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) core.info(`${wfName} artifactNames: ${JSON.stringify(artifactNames)}`); // check for new combined artifact - const combinedArtifact = artifactNames.find(name => name.startsWith("arm-analysis-results=")); + const combinedArtifact = artifactNames.find(name => name.startsWith("arm-auto-signoff-code-results=")); if (combinedArtifact) { try { - const resultsJson = combinedArtifact.substring("arm-analysis-results=".length); + const resultsJson = combinedArtifact.substring("arm-auto-signoff-code-results=".length); const results = JSON.parse(resultsJson); core.info(`Combined ARM analysis results: ${JSON.stringify(results)}`); @@ -235,7 +236,8 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) if (results.incrementalTypeSpec) { labelsToAdd.push("ARMAutoSignedOff-IncrementalTSP"); } - if (results.trivialChanges?.anyTrivialChanges) { + // Check if trivial changes exist using the new PullRequestChanges structure + if (results.trivialChanges && isTrivialPullRequest(results.trivialChanges)) { labelsToAdd.push("ARMAutoSignedOff-Trivial"); } diff --git a/.github/workflows/src/arm-incremental-typespec.js b/.github/workflows/src/arm-auto-signoff/incremental-typespec.js similarity index 100% rename from .github/workflows/src/arm-incremental-typespec.js rename to .github/workflows/src/arm-auto-signoff/incremental-typespec.js diff --git a/.github/workflows/src/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js similarity index 50% rename from .github/workflows/src/trivial-changes-check.js rename to .github/workflows/src/arm-auto-signoff/trivial-changes-check.js index d19f2aa96a3e..cace684dbb61 100644 --- a/.github/workflows/src/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -6,7 +6,16 @@ import { getChangedFilesStatuses, json, example, -} from "../../shared/src/changed-files.js"; + markdown, + resourceManager, +} from "../../../shared/src/changed-files.js"; +import { + createEmptyPullRequestChanges, + isTrivialPullRequest, + isDocumentationOnly, + isExamplesOnly, + isNonFunctionalOnly, +} from "../../../shared/src/pr-changes.js"; import { CoreLogger } from "./core-logger.js"; // Enable simple-git debug logging to improve console output @@ -45,160 +54,197 @@ const NON_FUNCTIONAL_PROPERTIES = new Set([ ]); /** + * Analyzes a PR to determine what types of changes it contains * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments - * @returns {Promise<{documentationOnlyChanges: boolean, examplesOnlyChanges: boolean, nonFunctionalChanges: boolean, anyTrivialChanges: boolean}>} + * @returns {Promise} JSON string of {@link import('../../../shared/src/pr-changes.js').PullRequestChanges} */ -export default async function checkTrivialChanges({ core }) { +export default async function checkTrivialChanges({ core, context }) { + // Get the actual PR base branch from GitHub context + // This works for any target branch (main, dev, release/*, etc.) + const baseBranch = context.payload.pull_request?.base?.ref || "main"; + const baseRef = `origin/${baseBranch}`; + + core.info(`Comparing against base branch: ${baseRef}`); + const options = { cwd: process.env.GITHUB_WORKSPACE, logger: new CoreLogger(core), + // Compare against the actual PR base branch + baseCommitish: baseRef, }; const changedFiles = await getChangedFiles(options); const changedFilesStatuses = await getChangedFilesStatuses(options); const git = simpleGit(options.cwd); - if (changedFiles.length === 0) { - core.info("No changes detected in PR"); - return { - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, - }; + // Filter to only resource-manager files for ARM auto-signoff + const changedRmFiles = changedFiles.filter(resourceManager); + const changedRmFilesStatuses = { + additions: changedFilesStatuses.additions.filter(resourceManager), + modifications: changedFilesStatuses.modifications.filter(resourceManager), + deletions: changedFilesStatuses.deletions.filter(resourceManager), + renames: changedFilesStatuses.renames.filter(r => resourceManager(r.to) || resourceManager(r.from)) + }; + + if (changedRmFiles.length === 0) { + core.info("No resource-manager changes detected in PR"); + return JSON.stringify(createEmptyPullRequestChanges()); } - // Check for documentation-only changes (.md files) - const documentationFiles = changedFiles.filter(file => - file.toLowerCase().endsWith('.md') + // Check if PR contains non-resource-manager changes + const hasNonRmChanges = changedFiles.length > changedRmFiles.length; + if (hasNonRmChanges) { + const nonRmFiles = changedFiles.filter(file => !resourceManager(file)); + core.info(`PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff`); + core.info(`Non-RM files: ${nonRmFiles.slice(0, 5).join(', ')}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ''}`); + const result = createEmptyPullRequestChanges(); + result.other = true; + return JSON.stringify(result); + } + + // Check for non-trivial file operations (additions, deletions, renames) + const hasNonTrivialFileOperations = checkForNonTrivialFileOperations(changedRmFilesStatuses, core); + if (hasNonTrivialFileOperations) { + core.info("Non-trivial file operations detected (new spec files, deletions, or renames)"); + // These are functional changes by policy + const result = createEmptyPullRequestChanges(); + result.functional = true; + return JSON.stringify(result); + } + + // Analyze what types of changes are present + const changes = await analyzePullRequestChanges(changedRmFiles, git, core, baseRef); + + core.info(`PR Changes: ${JSON.stringify(changes)}`); + core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + + return JSON.stringify(changes); +} + +/** + * Checks for non-trivial file operations: additions/deletions of spec files, or any renames + * @param {{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[]}} changedFilesStatuses - File status information + * @param {typeof import("@actions/core")} core - Core logger + * @returns {boolean} - True if non-trivial operations detected + */ +function checkForNonTrivialFileOperations(changedFilesStatuses, core) { + // New spec files (non-example JSON) are non-trivial + const newSpecFiles = changedFilesStatuses.additions.filter(file => + json(file) && !example(file) && !markdown(file) ); - const documentationOnlyChanges = documentationFiles.length > 0 && - documentationFiles.length === changedFiles.length; + if (newSpecFiles.length > 0) { + core.info(`Non-trivial: New spec files detected: ${newSpecFiles.join(', ')}`); + return true; + } - core.info(`Documentation files changed: ${documentationFiles.length}`); - core.info(`Documentation-only changes: ${documentationOnlyChanges}`); + // Deleted spec files (non-example JSON) are non-trivial + const deletedSpecFiles = changedFilesStatuses.deletions.filter(file => + json(file) && !example(file) && !markdown(file) + ); + if (deletedSpecFiles.length > 0) { + core.info(`Non-trivial: Deleted spec files detected: ${deletedSpecFiles.join(', ')}`); + return true; + } + + // Any file renames/moves are non-trivial (conservative approach) + if (changedFilesStatuses.renames.length > 0) { + core.info(`Non-trivial: File renames detected: ${changedFilesStatuses.renames.map(r => `${r.from} → ${r.to}`).join(', ')}`); + return true; + } + + // Deletions of docs/examples are OK, but already filtered above + return false; +} + +/** + * Analyzes a PR to determine what types of changes it contains + * @param {string[]} changedFiles - Array of changed file paths + * @param {import('simple-git').SimpleGit} git - Git instance + * @param {typeof import("@actions/core")} core - Core logger + * @param {string} baseRef - The base branch reference (e.g., "origin/main") + * @returns {Promise} + */ +async function analyzePullRequestChanges(changedFiles, git, core, baseRef) { + const changes = createEmptyPullRequestChanges(); - // Check for examples-only changes + // Categorize files by type + const documentationFiles = changedFiles.filter(markdown); const exampleFiles = changedFiles.filter(example); - const examplesOnlyChanges = exampleFiles.length > 0 && - exampleFiles.length === changedFiles.length; + const specFiles = changedFiles.filter(json).filter(file => !example(file)); + const otherFiles = changedFiles.filter(file => !json(file) && !markdown(file)); - core.info(`Example files changed: ${exampleFiles.length}`); - core.info(`Examples-only changes: ${examplesOnlyChanges}`); + core.info(`File breakdown: ${documentationFiles.length} docs, ${exampleFiles.length} examples, ${specFiles.length} specs, ${otherFiles.length} other`); - // Check what types of non-JSON files we have (used for multiple checks) - // Note: Example files are JSON files, so they won't be in this list - const nonJsonFiles = changedFiles.filter(file => !json(file)); - const nonJsonFilesAreTrivial = nonJsonFiles.every(file => - file.toLowerCase().endsWith('.md') - ); + // Set flags for file types present + if (documentationFiles.length > 0) { + changes.documentation = true; + } - // Check for non-functional JSON changes - let nonFunctionalChanges = false; - const jsonFiles = changedFiles.filter(json).filter(file => !example(file)); - - if (jsonFiles.length > 0) { - core.info(`Analyzing ${jsonFiles.length} JSON files for non-functional changes...`); - - // Check if all JSON changes are non-functional - let allJsonChangesNonFunctional = true; + if (exampleFiles.length > 0) { + changes.examples = true; + } + + if (otherFiles.length > 0) { + changes.other = true; + } + + // Analyze spec files to determine if functional or non-functional + if (specFiles.length > 0) { + core.info(`Analyzing ${specFiles.length} spec files...`); - for (const file of jsonFiles) { + let hasFunctionalChanges = false; + let hasNonFunctionalChanges = false; + + for (const file of specFiles) { core.info(`Analyzing file: ${file}`); try { - const isNonFunctional = await analyzeJsonFileChanges(file, git, core); - if (!isNonFunctional) { + const isNonFunctional = await analyzeSpecFileForFunctionalChanges(file, git, core, baseRef); + if (isNonFunctional) { + hasNonFunctionalChanges = true; + core.info(`File ${file} contains only non-functional changes`); + } else { + hasFunctionalChanges = true; core.info(`File ${file} contains functional changes`); - allJsonChangesNonFunctional = false; + // Once we find functional changes, we can stop break; - } else { - core.info(`File ${file} contains only non-functional changes`); } } catch (error) { core.warning(`Failed to analyze ${file}: ${error.message}`); - allJsonChangesNonFunctional = false; + // On error, treat as functional to be conservative + hasFunctionalChanges = true; break; } } - - // Non-functional changes if: - // 1. All non-example JSON files have only non-functional changes, AND - // 2. Any remaining files are documentation (.md files) - // Note: Example JSON files are already considered trivial by design - nonFunctionalChanges = allJsonChangesNonFunctional && nonJsonFilesAreTrivial; - } - core.info(`Non-functional changes: ${nonFunctionalChanges}`); - - // Calculate if ANY trivial changes exist - // This should be true if ALL changed files are trivial types: - // - Documentation files (.md) - // - Example files (/examples/*.json) - // - JSON files with only non-functional changes - const trivialFileTypes = documentationFiles.length + exampleFiles.length; - const functionalJsonFiles = jsonFiles.filter(async (file) => { - try { - return !(await analyzeJsonFileChanges(file, git, core)); - } catch { - return true; // Treat errors as functional changes - } - }); - - // For anyTrivialChanges, we need to check if ALL files are trivial - // This is different from the individual "only" checks - let anyTrivialChanges; - if (documentationOnlyChanges || examplesOnlyChanges) { - // Pure documentation or pure examples - anyTrivialChanges = true; - } else if (nonFunctionalChanges) { - // JSON files with only non-functional changes (and possibly docs) - anyTrivialChanges = true; - } else if (jsonFiles.length === 0 && trivialFileTypes === changedFiles.length) { - // Mixed documentation + examples (no JSON files to analyze) - anyTrivialChanges = true; - } else { - // Some files are functional changes - anyTrivialChanges = false; + changes.functional = hasFunctionalChanges; + changes.nonFunctional = hasNonFunctionalChanges; } - core.info(`Any trivial changes: ${anyTrivialChanges}`); - - const result = { - documentationOnlyChanges, - examplesOnlyChanges, - nonFunctionalChanges, - anyTrivialChanges, - }; - - core.info(`Final result: ${JSON.stringify(result)}`); - - return result; + return changes; } /** - * Analyzes changes in a JSON file to determine if they are non-functional + * Analyzes changes in a spec file to determine if they are functional or non-functional + * Note: New files and deletions should already be filtered by checkForNonTrivialFileOperations * @param {string} file - The file path * @param {import('simple-git').SimpleGit} git - Git instance * @param {typeof import("@actions/core")} core - Core logger - * @returns {Promise} - True if changes are non-functional + * @param {string} baseRef - The base branch reference (e.g., "origin/main") + * @returns {Promise} - True if changes are non-functional only */ -async function analyzeJsonFileChanges(file, git, core) { +async function analyzeSpecFileForFunctionalChanges(file, git, core, baseRef) { let baseContent; let headContent; try { - baseContent = await git.show([`HEAD^:${file}`]); + // Get file content from PR base branch + baseContent = await git.show([`${baseRef}:${file}`]); } catch (e) { if (e instanceof Error && e.message.includes("does not exist")) { - // New file - consider it functional change unless it's pure metadata - core.info(`File ${file} is new`); - try { - headContent = await git.show([`HEAD:${file}`]); - return isNewFileNonFunctional(headContent, core); - } catch { - return false; - } + // New file - should have been caught earlier, but treat as functional to be safe + core.info(`File ${file} is new - treating as functional`); + return false; } else { throw e; } @@ -208,8 +254,8 @@ async function analyzeJsonFileChanges(file, git, core) { headContent = await git.show([`HEAD:${file}`]); } catch (e) { if (e instanceof Error && e.message.includes("does not exist")) { - // File deleted - consider it functional change - core.info(`File ${file} was deleted`); + // File deleted - should have been caught earlier, but treat as functional to be safe + core.info(`File ${file} was deleted - treating as functional`); return false; } else { throw e; @@ -229,22 +275,7 @@ async function analyzeJsonFileChanges(file, git, core) { return analyzeJsonDifferences(baseJson, headJson, "", core); } -/** - * Analyzes if a new JSON file contains only non-functional content - * @param {string} content - JSON content - * @param {typeof import("@actions/core")} core - Core logger - * @returns {boolean} - True if non-functional - */ -function isNewFileNonFunctional(content, core) { - try { - const jsonObj = JSON.parse(content); - // For new files, we're more conservative - only consider it non-functional - // if it's clearly metadata-only (like a new example file) - return false; // Conservative approach for new files - } catch { - return false; - } -} + /** * Recursively analyzes differences between two JSON objects @@ -364,16 +395,5 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { */ function isNonFunctionalArrayChange(baseArray, headArray, path) { const currentProperty = path.split('.').pop() || path; - - // Tags array changes are typically non-functional - if (currentProperty === 'tags') { - return true; - } - - // Examples array changes are typically non-functional - if (currentProperty === 'examples' || currentProperty === 'x-ms-examples') { - return true; - } - - return false; + return NON_FUNCTIONAL_PROPERTIES.has(currentProperty); } \ No newline at end of file diff --git a/.github/workflows/test/arm-auto-signoff.test.js b/.github/workflows/test/arm-auto-signoff.test.js index f95c98e1dcee..7b35a5d2973d 100644 --- a/.github/workflows/test/arm-auto-signoff.test.js +++ b/.github/workflows/test/arm-auto-signoff.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { CommitStatusState } from "../../shared/src/github.js"; -import { getLabelActionImpl } from "../src/arm-auto-signoff.js"; +import { getLabelActionImpl } from "../src/arm-auto-signoff-status.js"; import { LabelAction } from "../src/label.js"; import { createMockCore, createMockGithub as createMockGithubBase } from "./mocks.js"; diff --git a/.github/workflows/test/trivial-changes-check.test.js b/.github/workflows/test/trivial-changes-check.test.js index b3fc94cb1c48..c7f30423f653 100644 --- a/.github/workflows/test/trivial-changes-check.test.js +++ b/.github/workflows/test/trivial-changes-check.test.js @@ -8,10 +8,11 @@ vi.mock("simple-git", () => ({ import * as simpleGit from "simple-git"; import * as changedFiles from "../../shared/src/changed-files.js"; -import checkTrivialChangesImpl from "../src/trivial-changes-check.js"; -import { createMockCore } from "./mocks.js"; +import checkTrivialChangesImpl from "../src/arm-auto-signoff/trivial-changes-check.js"; +import { createMockCore, createMockContext } from "./mocks.js"; const core = createMockCore(); +const context = createMockContext(); /** * @param {unknown} asyncFunctionArgs @@ -33,10 +34,14 @@ describe("checkTrivialChanges", () => { it("returns all false if no changed files", async () => { vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([]); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: [], deletions: [], renames: [] + }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -46,16 +51,19 @@ describe("checkTrivialChanges", () => { it("returns documentation only changes for .md files", async () => { const mdFiles = [ - "specification/someservice/README.md", - "documentation/some-doc.md", - "docs/api-guide.md" + "specification/someservice/resource-manager/readme.md", + "specification/someservice/resource-manager/Microsoft.Service/preview/2021-01-01/README.md" ]; vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mdFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: mdFiles, deletions: [], renames: [] + }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: true, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -65,15 +73,19 @@ describe("checkTrivialChanges", () => { it("returns examples only changes for example files", async () => { const exampleFiles = [ - "specification/someservice/examples/Get.json", - "specification/anotherservice/examples/Create.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json" ]; vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(exampleFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: exampleFiles, deletions: [], renames: [] + }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: true, nonFunctionalChanges: false, @@ -83,7 +95,7 @@ describe("checkTrivialChanges", () => { it("returns non-functional changes for JSON files with only non-functional properties", async () => { const jsonFiles = [ - "specification/someservice/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; const oldJson = JSON.stringify({ @@ -101,12 +113,15 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: jsonFiles, deletions: [], renames: [] + }); const showSpy = vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { if (Array.isArray(args)) { const ref = args[0]; - if (ref.startsWith("HEAD^:")) { + if (ref.includes("origin/main:")) { return Promise.resolve(oldJson); } else if (ref.startsWith("HEAD:")) { return Promise.resolve(newJson); @@ -115,22 +130,22 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: true, anyTrivialChanges: true, }); - expect(showSpy).toHaveBeenCalledWith([`HEAD^:${jsonFiles[0]}`]); - expect(showSpy).toHaveBeenCalledWith([`HEAD:${jsonFiles[0]}`]); + expect(showSpy).toHaveBeenCalled(); }); it("returns false for functional changes in JSON files", async () => { const jsonFiles = [ - "specification/someservice/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; const oldJson = JSON.stringify({ @@ -147,6 +162,7 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({}); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { @@ -162,8 +178,9 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges({ core }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -173,9 +190,9 @@ describe("checkTrivialChanges", () => { it("handles mixed trivial and non-trivial changes", async () => { const mixedFiles = [ - "specification/someservice/README.md", // Documentation (trivial) - "specification/someservice/examples/Get.json", // Examples (trivial) - "specification/someservice/stable/2021-01-01/service.json" // Functional change (non-trivial) + "specification/someservice/resource-manager/Microsoft.Service/README.md", // Documentation (trivial) + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", // Examples (trivial) + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" // Functional change (non-trivial) ]; const oldJson = JSON.stringify({ @@ -190,13 +207,16 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: mixedFiles, deletions: [], renames: [] + }); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { if (Array.isArray(args)) { const ref = args[0]; if (ref.includes("service.json")) { - if (ref.startsWith("HEAD^:")) { + if (ref.includes("origin/main:")) { return Promise.resolve(oldJson); } else if (ref.startsWith("HEAD:")) { return Promise.resolve(newJson); @@ -206,10 +226,11 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); // Should detect functional changes, so overall not trivial - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -219,16 +240,19 @@ describe("checkTrivialChanges", () => { it("handles JSON parsing errors gracefully", async () => { const jsonFiles = [ - "specification/someservice/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: jsonFiles, deletions: [], renames: [] + }); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { if (Array.isArray(args)) { const ref = args[0]; - if (ref.startsWith("HEAD^:")) { + if (ref.includes("origin/main:")) { return Promise.resolve("{ invalid json"); } else if (ref.startsWith("HEAD:")) { return Promise.resolve("{ also invalid }"); @@ -237,10 +261,11 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); // Should treat JSON parsing errors as non-trivial changes - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -250,18 +275,22 @@ describe("checkTrivialChanges", () => { it("handles git show errors gracefully", async () => { const jsonFiles = [ - "specification/someservice/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: jsonFiles, deletions: [], renames: [] + }); vi.mocked(simpleGit.simpleGit().show) .mockRejectedValue(new Error("Git operation failed")); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); // Should treat git errors as non-trivial changes - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -271,7 +300,7 @@ describe("checkTrivialChanges", () => { it("handles nested non-functional property changes", async () => { const jsonFiles = [ - "specification/someservice/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; const oldJson = JSON.stringify({ @@ -304,6 +333,7 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({}); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { @@ -319,8 +349,9 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges({ core }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: true, @@ -330,7 +361,7 @@ describe("checkTrivialChanges", () => { it("detects functional changes in nested properties", async () => { const jsonFiles = [ - "specification/someservice/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; const oldJson = JSON.stringify({ @@ -361,6 +392,7 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({}); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { @@ -376,8 +408,9 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges({ core }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, examplesOnlyChanges: false, nonFunctionalChanges: false, @@ -387,17 +420,20 @@ describe("checkTrivialChanges", () => { it("handles mixed documentation and examples correctly", async () => { const mixedTrivialFiles = [ - "specification/someservice/README.md", - "docs/changelog.md", - "specification/someservice/examples/Get.json", - "specification/anotherservice/examples/Create.json" + "specification/someservice/resource-manager/Microsoft.Service/README.md", + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json" ]; vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedTrivialFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: mixedTrivialFiles, deletions: [], renames: [] + }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); - expect(result).toEqual({ + expect(parsed).toEqual({ documentationOnlyChanges: false, // Not ONLY documentation examplesOnlyChanges: false, // Not ONLY examples nonFunctionalChanges: false, // No JSON analysis needed From be72bbbd44d50dff49d9ff5bba50b6c472fc3bc1 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 4 Dec 2025 13:21:21 -0600 Subject: [PATCH 03/36] Fix references with file move --- .../src/arm-auto-signoff/arm-auto-signoff-status.js | 8 ++++---- .../src/arm-auto-signoff/incremental-typespec.js | 8 ++++---- .../src/arm-auto-signoff/trivial-changes-check.js | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index ffe6fa894558..de25f50753c4 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -1,8 +1,8 @@ import { inspect } from "util"; -import { CommitStatusState, PER_PAGE_MAX } from "../../shared/src/github.js"; -import { equals } from "../../shared/src/set.js"; -import { byDate, invert } from "../../shared/src/sort.js"; -import { extractInputs } from "./context.js"; +import { CommitStatusState, PER_PAGE_MAX } from "../../../shared/src/github.js"; +import { equals } from "../../../shared/src/set.js"; +import { byDate, invert } from "../../../shared/src/sort.js"; +import { extractInputs } from "../context.js"; import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; // TODO: Add tests diff --git a/.github/workflows/src/arm-auto-signoff/incremental-typespec.js b/.github/workflows/src/arm-auto-signoff/incremental-typespec.js index 5bd6a651071a..f69cef97efb5 100644 --- a/.github/workflows/src/arm-auto-signoff/incremental-typespec.js +++ b/.github/workflows/src/arm-auto-signoff/incremental-typespec.js @@ -8,10 +8,10 @@ import { readme, resourceManager, swagger, -} from "../../shared/src/changed-files.js"; -import { Readme } from "../../shared/src/readme.js"; -import { Swagger } from "../../shared/src/swagger.js"; -import { CoreLogger } from "./core-logger.js"; +} from "../../../shared/src/changed-files.js"; +import { Readme } from "../../../shared/src/readme.js"; +import { Swagger } from "../../../shared/src/swagger.js"; +import { CoreLogger } from "../core-logger.js"; // Enable simple-git debug logging to improve console output debug.enable("simple-git"); diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index cace684dbb61..e024287f5fb7 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -16,7 +16,7 @@ import { isExamplesOnly, isNonFunctionalOnly, } from "../../../shared/src/pr-changes.js"; -import { CoreLogger } from "./core-logger.js"; +import { CoreLogger } from "../core-logger.js"; // Enable simple-git debug logging to improve console output debug.enable("simple-git"); From 056a31b1165b2211dcc78485128eea0640359221 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 4 Dec 2025 14:58:20 -0600 Subject: [PATCH 04/36] Fix invalid chars in upload artifact step --- .../arm-auto-signoff/arm-auto-signoff-code.js | 3 ++- .../arm-auto-signoff-status.js | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 1a109c80f2cd..826814160636 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -44,5 +44,6 @@ export default async function armAutoSignOffCode({ github, context, core }) { core.info(`Combined result: ${JSON.stringify(combined, null, 2)}`); core.info(`Qualifies for auto sign-off: ${combined.qualifiesForAutoSignOff}`); - return JSON.stringify(combined); + // Return key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true + return `incrementalTypeSpec-${incrementalTypeSpecResult},isTrivial-${isTrivial},qualifies-${qualifiesForAutoSignOff}`; } diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index de25f50753c4..f2d7c2789001 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -3,7 +3,6 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../../shared/src/github.js"; import { equals } from "../../../shared/src/set.js"; import { byDate, invert } from "../../../shared/src/sort.js"; import { extractInputs } from "../context.js"; -import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; // TODO: Add tests /* v8 ignore start */ @@ -226,19 +225,25 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) const combinedArtifact = artifactNames.find(name => name.startsWith("arm-auto-signoff-code-results=")); if (combinedArtifact) { try { - const resultsJson = combinedArtifact.substring("arm-auto-signoff-code-results=".length); - const results = JSON.parse(resultsJson); + const resultValue = combinedArtifact.substring("arm-auto-signoff-code-results=".length); + // Parse key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true + // Split by comma, then parse each key-value pair + const keyValuePairs = resultValue.split(','); - core.info(`Combined ARM analysis results: ${JSON.stringify(results)}`); + // Extract values directly by key name + const incrementalTypeSpec = keyValuePairs[0]?.endsWith('-true') ?? false; + const isTrivial = keyValuePairs[1]?.endsWith('-true') ?? false; + const qualifiesForAutoSignOff = keyValuePairs[2]?.endsWith('-true') ?? false; - if (results.qualifiesForAutoSignOff) { + core.info(`ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}, qualifiesForAutoSignOff=${qualifiesForAutoSignOff}`); + + if (qualifiesForAutoSignOff) { const labelsToAdd = []; - if (results.incrementalTypeSpec) { + if (incrementalTypeSpec) { labelsToAdd.push("ARMAutoSignedOff-IncrementalTSP"); } - // Check if trivial changes exist using the new PullRequestChanges structure - if (results.trivialChanges && isTrivialPullRequest(results.trivialChanges)) { + if (isTrivial) { labelsToAdd.push("ARMAutoSignedOff-Trivial"); } From 177dab9f47e35db87607a1707bcaf2ca1d5af8f3 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Fri, 5 Dec 2025 10:47:32 -0600 Subject: [PATCH 05/36] Fix tests --- .../workflows/arm-auto-signoff-status.yaml | 2 +- .../workflows/test/arm-auto-signoff.test.js | 75 +++++------ .../test/arm-incremental-typespec.test.js | 2 +- .../test/trivial-changes-check.test.js | 123 ++++++++++-------- 4 files changed, 103 insertions(+), 99 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index d75b3c95599b..186899c1742e 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -12,7 +12,7 @@ on: - unlabeled workflow_run: workflows: - ["ARM Auto-SignOff- Analyze Code", "Swagger Avocado - Set Status", "Swagger LintDiff - Set Status"] + ["ARM Auto-SignOff - Analyze Code", "Swagger Avocado - Set Status", "Swagger LintDiff - Set Status"] types: [completed] permissions: diff --git a/.github/workflows/test/arm-auto-signoff.test.js b/.github/workflows/test/arm-auto-signoff.test.js index 7b35a5d2973d..410967fc644e 100644 --- a/.github/workflows/test/arm-auto-signoff.test.js +++ b/.github/workflows/test/arm-auto-signoff.test.js @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { CommitStatusState } from "../../shared/src/github.js"; -import { getLabelActionImpl } from "../src/arm-auto-signoff-status.js"; -import { LabelAction } from "../src/label.js"; +import { getLabelActionImpl } from "../src/arm-auto-signoff/arm-auto-signoff-status.js"; import { createMockCore, createMockGithub as createMockGithubBase } from "./mocks.js"; const core = createMockCore(); @@ -23,7 +22,7 @@ function createMockGithub({ incrementalTypeSpec }) { conclusion: null, }, { - name: "ARM Incremental TypeSpec", + name: "ARM Auto-SignOff - Analyze Code", id: 456, status: "completed", conclusion: "success", @@ -32,9 +31,11 @@ function createMockGithub({ incrementalTypeSpec }) { }, }); + // Return artifact in the format: arm-auto-signoff-code-results=incrementalTypeSpec-VALUE,isTrivial-VALUE,qualifies-VALUE + const qualifies = incrementalTypeSpec ? "true" : "false"; github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ data: { - artifacts: [{ name: `incremental-typespec=${incrementalTypeSpec}` }], + artifacts: [{ name: `arm-auto-signoff-code-results=incrementalTypeSpec-${qualifies},isTrivial-false,qualifies-${qualifies}` }], }, }); @@ -81,13 +82,13 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.None, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); }); it("removes label if not incremental typespec", async () => { const github = createMockGithub({ incrementalTypeSpec: false }); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ - data: [{ name: "ARMAutoSignedOff" }], + data: [{ name: "ARMAutoSignedOff-IncrementalTSP" }], }); await expect( @@ -99,7 +100,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.Remove, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); }); it("no-ops if incremental typespec in progress", async () => { @@ -108,7 +109,7 @@ describe("getLabelActionImpl", () => { data: { workflow_runs: [ { - name: "ARM Incremental TypeSpec", + name: "ARM Auto-SignOff - Analyze Code", id: 456, status: "in_progress", conclusion: null, @@ -126,13 +127,13 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.None, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); }); it("removes label if no runs of incremental typespec", async () => { const github = createMockGithubBase(); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ - data: [{ name: "ARMAutoSignedOff" }], + data: [{ name: "ARMAutoSignedOff-IncrementalTSP" }], }); github.rest.actions.listWorkflowRunsForRepo.mockResolvedValue({ data: { @@ -149,26 +150,26 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.Remove, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); }); it("uses latest run of incremental typespec", async () => { const github = createMockGithubBase(); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ - data: [{ name: "ARMAutoSignedOff" }], + data: [{ name: "ARMAutoSignedOff-IncrementalTSP" }], }); github.rest.actions.listWorkflowRunsForRepo.mockResolvedValue({ data: { workflow_runs: [ { - name: "ARM Incremental TypeSpec", + name: "ARM Auto-SignOff - Analyze Code", id: 456, status: "completed", conclusion: "success", updated_at: "2020-01-22T19:33:08Z", }, { - name: "ARM Incremental TypeSpec", + name: "ARM Auto-SignOff - Analyze Code", id: 789, status: "completed", conclusion: "failure", @@ -187,13 +188,13 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.Remove, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); }); it.each([ - { labels: ["ARMAutoSignedOff"] }, - { labels: ["ARMAutoSignedOff", "ARMReview", "NotReadyForARMReview"] }, - { labels: ["ARMAutoSignedOff", "ARMReview", "SuppressionReviewRequired"] }, + { labels: ["ARMAutoSignedOff-IncrementalTSP"] }, + { labels: ["ARMAutoSignedOff-IncrementalTSP", "ARMReview", "NotReadyForARMReview"] }, + { labels: ["ARMAutoSignedOff-IncrementalTSP", "ARMReview", "SuppressionReviewRequired"] }, ])("removes label if not all labels match ($labels)", async ({ labels }) => { const github = createMockGithub({ incrementalTypeSpec: true }); @@ -210,7 +211,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.Remove, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); }); it.each(["Swagger Avocado", "Swagger LintDiff"])( @@ -219,7 +220,7 @@ describe("getLabelActionImpl", () => { const github = createMockGithub({ incrementalTypeSpec: true }); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ - data: [{ name: "ARMAutoSignedOff" }, { name: "ARMReview" }], + data: [{ name: "ARMAutoSignedOff-IncrementalTSP" }, { name: "ARMReview" }], }); github.rest.repos.listCommitStatusesForRef.mockResolvedValue({ data: [ @@ -239,16 +240,16 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.Remove, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); }, ); it.each([ - [CommitStatusState.ERROR, ["ARMReview", "ARMAutoSignedOff"]], - [CommitStatusState.FAILURE, ["ARMReview", "ARMAutoSignedOff"]], - [CommitStatusState.PENDING, ["ARMReview"]], - [CommitStatusState.SUCCESS, ["ARMReview"]], - ])("uses latest status if multiple (%o)", async (state, labels) => { + [CommitStatusState.ERROR, ["ARMReview", "ARMAutoSignedOff-IncrementalTSP"], []], + [CommitStatusState.FAILURE, ["ARMReview", "ARMAutoSignedOff-IncrementalTSP"], []], + [CommitStatusState.PENDING, ["ARMReview"], undefined], + [CommitStatusState.SUCCESS, ["ARMReview"], ["ARMAutoSignedOff-IncrementalTSP"]], + ])("uses latest status if multiple (%o)", async (state, labels, expectedAutoSignOffLabels) => { const github = createMockGithub({ incrementalTypeSpec: true }); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ @@ -277,20 +278,6 @@ describe("getLabelActionImpl", () => { ], }); - let expectedAction; - switch (state) { - case CommitStatusState.ERROR: - case CommitStatusState.FAILURE: - expectedAction = LabelAction.Remove; - break; - case CommitStatusState.SUCCESS: - expectedAction = LabelAction.Add; - break; - case CommitStatusState.PENDING: - expectedAction = LabelAction.None; - break; - } - await expect( getLabelActionImpl({ owner: "TestOwner", @@ -300,7 +287,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: expectedAction, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: expectedAutoSignOffLabels }); }); it("no-ops if check not found or not completed", async () => { @@ -322,7 +309,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.None, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); github.rest.repos.listCommitStatusesForRef.mockResolvedValue({ data: [{ context: "Swagger LintDiff", state: CommitStatusState.PENDING }], @@ -336,7 +323,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.None, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); }); it("adds label if incremental tsp, labels match, and check succeeded", async () => { @@ -367,6 +354,6 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ labelAction: LabelAction.Add, headSha: "abc123", issueNumber: 123 }); + ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: ["ARMAutoSignedOff-IncrementalTSP"] }); }); }); diff --git a/.github/workflows/test/arm-incremental-typespec.test.js b/.github/workflows/test/arm-incremental-typespec.test.js index e8ff956ee6e9..d51fac2e37b1 100644 --- a/.github/workflows/test/arm-incremental-typespec.test.js +++ b/.github/workflows/test/arm-incremental-typespec.test.js @@ -19,7 +19,7 @@ import { swaggerHandWritten, swaggerTypeSpecGenerated, } from "../../shared/test/examples.js"; -import incrementalTypeSpecImpl from "../src/arm-incremental-typespec.js"; +import incrementalTypeSpecImpl from "../src/arm-auto-signoff/incremental-typespec.js"; import { createMockCore } from "./mocks.js"; const core = createMockCore(); diff --git a/.github/workflows/test/trivial-changes-check.test.js b/.github/workflows/test/trivial-changes-check.test.js index c7f30423f653..8a60100148ae 100644 --- a/.github/workflows/test/trivial-changes-check.test.js +++ b/.github/workflows/test/trivial-changes-check.test.js @@ -42,10 +42,11 @@ describe("checkTrivialChanges", () => { const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, + documentation: false, + examples: false, + functional: false, + nonFunctional: false, + other: false, }); }); @@ -64,10 +65,11 @@ describe("checkTrivialChanges", () => { const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: true, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: true, + documentation: true, + examples: false, + functional: false, + nonFunctional: false, + other: false, }); }); @@ -86,10 +88,11 @@ describe("checkTrivialChanges", () => { const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: true, - nonFunctionalChanges: false, - anyTrivialChanges: true, + documentation: false, + examples: true, + functional: false, + nonFunctional: false, + other: false, }); }); @@ -134,10 +137,11 @@ describe("checkTrivialChanges", () => { const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: true, - anyTrivialChanges: true, + documentation: false, + examples: false, + functional: false, + nonFunctional: true, + other: false, }); expect(showSpy).toHaveBeenCalled(); @@ -162,13 +166,15 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); - vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({}); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: jsonFiles, deletions: [], renames: [] + }); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { if (Array.isArray(args)) { const ref = args[0]; - if (ref.startsWith("HEAD^:")) { + if (ref.includes("origin/main:")) { return Promise.resolve(oldJson); } else if (ref.startsWith("HEAD:")) { return Promise.resolve(newJson); @@ -177,14 +183,15 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, + documentation: false, + examples: false, + functional: true, + nonFunctional: false, + other: false, }); }); @@ -231,10 +238,11 @@ describe("checkTrivialChanges", () => { // Should detect functional changes, so overall not trivial expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, + documentation: true, + examples: true, + functional: true, + nonFunctional: false, + other: false, }); }); @@ -266,10 +274,11 @@ describe("checkTrivialChanges", () => { // Should treat JSON parsing errors as non-trivial changes expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, + documentation: false, + examples: false, + functional: true, + nonFunctional: false, + other: false, }); }); @@ -291,10 +300,11 @@ describe("checkTrivialChanges", () => { // Should treat git errors as non-trivial changes expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, + documentation: false, + examples: false, + functional: true, + nonFunctional: false, + other: false, }); }); @@ -333,13 +343,15 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); - vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({}); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: jsonFiles, deletions: [], renames: [] + }); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { if (Array.isArray(args)) { const ref = args[0]; - if (ref.startsWith("HEAD^:")) { + if (ref.includes("origin/main:")) { return Promise.resolve(oldJson); } else if (ref.startsWith("HEAD:")) { return Promise.resolve(newJson); @@ -348,14 +360,15 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: true, - anyTrivialChanges: true, + documentation: false, + examples: false, + functional: false, + nonFunctional: true, + other: false, }); }); @@ -392,13 +405,15 @@ describe("checkTrivialChanges", () => { }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); - vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({}); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: jsonFiles, deletions: [], renames: [] + }); vi.mocked(simpleGit.simpleGit().show) .mockImplementation((args) => { if (Array.isArray(args)) { const ref = args[0]; - if (ref.startsWith("HEAD^:")) { + if (ref.includes("origin/main:")) { return Promise.resolve(oldJson); } else if (ref.startsWith("HEAD:")) { return Promise.resolve(newJson); @@ -407,14 +422,15 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core }); + const result = await checkTrivialChanges({ core, context }); const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, - examplesOnlyChanges: false, - nonFunctionalChanges: false, - anyTrivialChanges: false, + documentation: false, + examples: false, + functional: true, + nonFunctional: false, + other: false, }); }); @@ -434,10 +450,11 @@ describe("checkTrivialChanges", () => { const parsed = JSON.parse(result); expect(parsed).toEqual({ - documentationOnlyChanges: false, // Not ONLY documentation - examplesOnlyChanges: false, // Not ONLY examples - nonFunctionalChanges: false, // No JSON analysis needed - anyTrivialChanges: true, // But it IS all trivial + documentation: true, + examples: true, + functional: false, + nonFunctional: false, + other: false, }); }); }); \ No newline at end of file From 422cd6a6e420ab426089e43534934eb827526eb2 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Tue, 9 Dec 2025 12:11:18 -0600 Subject: [PATCH 06/36] Address comments, Add more tests --- .github/shared/src/pr-changes.js | 24 +-- .../arm-auto-signoff/trivial-changes-check.js | 71 +++----- .../test/trivial-changes-check.test.js | 171 ++++++++++++++---- 3 files changed, 169 insertions(+), 97 deletions(-) diff --git a/.github/shared/src/pr-changes.js b/.github/shared/src/pr-changes.js index d2ed37061fa3..29818ad7a552 100644 --- a/.github/shared/src/pr-changes.js +++ b/.github/shared/src/pr-changes.js @@ -8,7 +8,6 @@ * @property {boolean} documentation - True if PR contains documentation (.md) file changes * @property {boolean} examples - True if PR contains example file changes (/examples/*.json) * @property {boolean} functional - True if PR contains functional spec changes (API-impacting) - * @property {boolean} nonFunctional - True if PR contains non-functional spec changes (metadata only) * @property {boolean} other - True if PR contains other file types (config, scripts, etc.) */ @@ -21,7 +20,6 @@ export function createEmptyPullRequestChanges() { documentation: false, examples: false, functional: false, - nonFunctional: false, other: false, }; } @@ -31,7 +29,6 @@ export function createEmptyPullRequestChanges() { * A PR is trivial if it contains only: * - Documentation changes * - Example changes - * - Non-functional spec changes * And does NOT contain: * - Functional spec changes * - Other file types @@ -41,9 +38,9 @@ export function createEmptyPullRequestChanges() { */ export function isTrivialPullRequest(changes) { // Trivial if no functional changes and no other files - // Must have at least one of: documentation, examples, or nonFunctional + // Must have at least one of: documentation, examples const hasNoBlockingChanges = !changes.functional && !changes.other; - const hasTrivialChanges = changes.documentation || changes.examples || changes.nonFunctional; + const hasTrivialChanges = changes.documentation || changes.examples; return hasNoBlockingChanges && hasTrivialChanges; } @@ -56,8 +53,7 @@ export function isTrivialPullRequest(changes) { export function isDocumentationOnly(changes) { return changes.documentation && !changes.examples && - !changes.functional && - !changes.nonFunctional && + !changes.functional && !changes.other; } @@ -69,18 +65,6 @@ export function isDocumentationOnly(changes) { export function isExamplesOnly(changes) { return !changes.documentation && changes.examples && - !changes.functional && - !changes.nonFunctional && - !changes.other; -} - -/** - * Checks if a PR contains only non-functional spec changes (with optional docs/examples) - * @param {PullRequestChanges} changes - The PR changes object - * @returns {boolean} - True if only non-functional changes (+ optional docs/examples) - */ -export function isNonFunctionalOnly(changes) { - return changes.nonFunctional && - !changes.functional && + !changes.functional && !changes.other; } diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index e024287f5fb7..8f31b2e50ff2 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -36,29 +36,18 @@ const NON_FUNCTIONAL_PROPERTIES = new Set([ 'example', 'examples', 'x-ms-client-name', - 'x-ms-parameter-name', - 'x-ms-enum', - 'x-ms-discriminator-value', - 'x-ms-client-flatten', - 'x-ms-azure-resource', - 'x-ms-mutability', - 'x-ms-secret', - 'x-ms-parameter-location', - 'x-ms-skip-url-encoding', - 'x-ms-long-running-operation', - 'x-ms-long-running-operation-options', - 'x-ms-pageable', - 'x-ms-odata', - 'tags', - 'x-ms-api-annotation' + 'tags' ]); /** * Analyzes a PR to determine what types of changes it contains - * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments - * @returns {Promise} JSON string of {@link import('../../../shared/src/pr-changes.js').PullRequestChanges} + * @param {{ core: import('@actions/core'), context: import('@actions/github').context }} args + * @returns {Promise} JSON string with categorized change types */ export default async function checkTrivialChanges({ core, context }) { + // Create result object once at the top + const changes = createEmptyPullRequestChanges(); + // Get the actual PR base branch from GitHub context // This works for any target branch (main, dev, release/*, etc.) const baseBranch = context.payload.pull_request?.base?.ref || "main"; @@ -86,9 +75,12 @@ export default async function checkTrivialChanges({ core, context }) { renames: changedFilesStatuses.renames.filter(r => resourceManager(r.to) || resourceManager(r.from)) }; + // Early exit if no resource-manager changes if (changedRmFiles.length === 0) { core.info("No resource-manager changes detected in PR"); - return JSON.stringify(createEmptyPullRequestChanges()); + core.info(`PR Changes: ${JSON.stringify(changes)}`); + core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + return JSON.stringify(changes); } // Check if PR contains non-resource-manager changes @@ -97,9 +89,10 @@ export default async function checkTrivialChanges({ core, context }) { const nonRmFiles = changedFiles.filter(file => !resourceManager(file)); core.info(`PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff`); core.info(`Non-RM files: ${nonRmFiles.slice(0, 5).join(', ')}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ''}`); - const result = createEmptyPullRequestChanges(); - result.other = true; - return JSON.stringify(result); + changes.other = true; + core.info(`PR Changes: ${JSON.stringify(changes)}`); + core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + return JSON.stringify(changes); } // Check for non-trivial file operations (additions, deletions, renames) @@ -107,13 +100,14 @@ export default async function checkTrivialChanges({ core, context }) { if (hasNonTrivialFileOperations) { core.info("Non-trivial file operations detected (new spec files, deletions, or renames)"); // These are functional changes by policy - const result = createEmptyPullRequestChanges(); - result.functional = true; - return JSON.stringify(result); + changes.functional = true; + core.info(`PR Changes: ${JSON.stringify(changes)}`); + core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + return JSON.stringify(changes); } - // Analyze what types of changes are present - const changes = await analyzePullRequestChanges(changedRmFiles, git, core, baseRef); + // Analyze what types of changes are present and update the changes object + await analyzePullRequestChanges(changedRmFiles, git, core, baseRef, changes); core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); @@ -157,16 +151,15 @@ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { } /** - * Analyzes a PR to determine what types of changes it contains + * Analyzes a PR to determine what types of changes it contains and updates the changes object * @param {string[]} changedFiles - Array of changed file paths * @param {import('simple-git').SimpleGit} git - Git instance * @param {typeof import("@actions/core")} core - Core logger * @param {string} baseRef - The base branch reference (e.g., "origin/main") - * @returns {Promise} + * @param {import('../../../shared/src/pr-changes.js').PullRequestChanges} changes - Changes object to update + * @returns {Promise} */ -async function analyzePullRequestChanges(changedFiles, git, core, baseRef) { - const changes = createEmptyPullRequestChanges(); - +async function analyzePullRequestChanges(changedFiles, git, core, baseRef, changes) { // Categorize files by type const documentationFiles = changedFiles.filter(markdown); const exampleFiles = changedFiles.filter(example); @@ -193,15 +186,13 @@ async function analyzePullRequestChanges(changedFiles, git, core, baseRef) { core.info(`Analyzing ${specFiles.length} spec files...`); let hasFunctionalChanges = false; - let hasNonFunctionalChanges = false; for (const file of specFiles) { core.info(`Analyzing file: ${file}`); try { - const isNonFunctional = await analyzeSpecFileForFunctionalChanges(file, git, core, baseRef); + const isNonFunctional = await analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef); if (isNonFunctional) { - hasNonFunctionalChanges = true; core.info(`File ${file} contains only non-functional changes`); } else { hasFunctionalChanges = true; @@ -218,24 +209,20 @@ async function analyzePullRequestChanges(changedFiles, git, core, baseRef) { } changes.functional = hasFunctionalChanges; - changes.nonFunctional = hasNonFunctionalChanges; } - - return changes; } /** - * Analyzes changes in a spec file to determine if they are functional or non-functional + * Analyzes changes in a spec file to determine if they are only non-functional * Note: New files and deletions should already be filtered by checkForNonTrivialFileOperations * @param {string} file - The file path * @param {import('simple-git').SimpleGit} git - Git instance * @param {typeof import("@actions/core")} core - Core logger * @param {string} baseRef - The base branch reference (e.g., "origin/main") - * @returns {Promise} - True if changes are non-functional only + * @returns {Promise} - True if changes are non-functional only, false if any functional changes detected */ -async function analyzeSpecFileForFunctionalChanges(file, git, core, baseRef) { - let baseContent; - let headContent; +async function analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef) { + let baseContent, headContent; try { // Get file content from PR base branch diff --git a/.github/workflows/test/trivial-changes-check.test.js b/.github/workflows/test/trivial-changes-check.test.js index 8a60100148ae..be2f7004e216 100644 --- a/.github/workflows/test/trivial-changes-check.test.js +++ b/.github/workflows/test/trivial-changes-check.test.js @@ -8,31 +8,22 @@ vi.mock("simple-git", () => ({ import * as simpleGit from "simple-git"; import * as changedFiles from "../../shared/src/changed-files.js"; -import checkTrivialChangesImpl from "../src/arm-auto-signoff/trivial-changes-check.js"; +import checkTrivialChanges from "../src/arm-auto-signoff/trivial-changes-check.js"; import { createMockCore, createMockContext } from "./mocks.js"; const core = createMockCore(); const context = createMockContext(); -/** - * @param {unknown} asyncFunctionArgs - */ -function checkTrivialChanges(asyncFunctionArgs) { - return checkTrivialChangesImpl( - /** @type {import("@actions/github-script").AsyncFunctionArguments} */ (asyncFunctionArgs), - ); -} - describe("checkTrivialChanges", () => { afterEach(() => { vi.clearAllMocks(); }); - it("throws if inputs null", async () => { + it("throws error when core and context are not provided", async () => { await expect(checkTrivialChanges({})).rejects.toThrow(); }); - it("returns all false if no changed files", async () => { + it("returns empty change flags when no files are changed", async () => { vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([]); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ additions: [], modifications: [], deletions: [], renames: [] @@ -45,12 +36,131 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: false, - nonFunctional: false, other: false, }); }); + + it("marks PR as 'other' when non-resource-manager files are changed", async () => { + const mixedFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", + "specification/someservice/data-plane/Service/stable/2021-01-01/service.json", // data-plane (non-RM) + "README.md", // root level file (non-RM) + ".github/workflows/ci.yml" // workflow file (non-RM) + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: mixedFiles, deletions: [], renames: [] + }); + + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + documentation: false, + examples: false, + functional: false, + other: true, + }); + }); + + it("marks PR as 'other' when only non-resource-manager files are changed", async () => { + const nonRmFiles = [ + "specification/someservice/data-plane/Service/stable/2021-01-01/service.json", + "package.json", + "tsconfig.json" + ]; - it("returns documentation only changes for .md files", async () => { + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(nonRmFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], modifications: nonRmFiles, deletions: [], renames: [] + }); + + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + documentation: false, + examples: false, + functional: false, + other: false, // No RM files at all, so returns empty + }); + }); + + it("detects functional changes when new spec files are added", async () => { + const filesWithAdditions = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newservice.json" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(filesWithAdditions); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: filesWithAdditions, // New spec file + modifications: [], + deletions: [], + renames: [] + }); + + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + documentation: false, + examples: false, + functional: true, // New spec files are functional + other: false, + }); + }); + + it("detects functional changes when spec files are deleted", async () => { + const deletedFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/oldservice.json" + ]; + + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(deletedFiles); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: [], + deletions: deletedFiles, // Deleted spec file + renames: [] + }); + + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + documentation: false, + examples: false, + functional: true, // Deleted spec files are functional + other: false, + }); + }); + + it("detects functional changes when spec files are renamed", async () => { + vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newname.json" + ]); + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: [], + deletions: [], + renames: [{ + from: "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/oldname.json", + to: "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newname.json" + }] + }); + + const result = await checkTrivialChanges({ core, context }); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + documentation: false, + examples: false, + functional: true, // Renamed spec files are functional + other: false, + }); + }); + + it("detects documentation-only changes when only .md files are modified", async () => { const mdFiles = [ "specification/someservice/resource-manager/readme.md", "specification/someservice/resource-manager/Microsoft.Service/preview/2021-01-01/README.md" @@ -68,12 +178,11 @@ describe("checkTrivialChanges", () => { documentation: true, examples: false, functional: false, - nonFunctional: false, other: false, }); }); - it("returns examples only changes for example files", async () => { + it("detects examples-only changes when only example JSON files are modified", async () => { const exampleFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json" @@ -91,12 +200,11 @@ describe("checkTrivialChanges", () => { documentation: false, examples: true, functional: false, - nonFunctional: false, other: false, }); }); - it("returns non-functional changes for JSON files with only non-functional properties", async () => { + it("identifies non-functional changes when only description and summary properties are modified", async () => { const jsonFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; @@ -112,7 +220,7 @@ describe("checkTrivialChanges", () => { version: "1.0", description: "New description" // Non-functional change }, - paths: { "/test": { get: { summary: "Test endpoint" } } } + paths: { "/test": { get: { summary: "Test endpoint updated" } } } // Non-functional change }); vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); @@ -140,14 +248,13 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: false, - nonFunctional: true, other: false, }); expect(showSpy).toHaveBeenCalled(); }); - it("returns false for functional changes in JSON files", async () => { + it("detects functional changes when new API endpoints are added", async () => { const jsonFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; @@ -190,12 +297,11 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: true, - nonFunctional: false, other: false, }); }); - it("handles mixed trivial and non-trivial changes", async () => { + it("detects functional changes even when mixed with documentation and example changes", async () => { const mixedFiles = [ "specification/someservice/resource-manager/Microsoft.Service/README.md", // Documentation (trivial) "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", // Examples (trivial) @@ -241,12 +347,11 @@ describe("checkTrivialChanges", () => { documentation: true, examples: true, functional: true, - nonFunctional: false, other: false, }); }); - it("handles JSON parsing errors gracefully", async () => { + it("treats JSON parsing errors as functional changes to be conservative", async () => { const jsonFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; @@ -277,12 +382,11 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: true, - nonFunctional: false, other: false, }); }); - it("handles git show errors gracefully", async () => { + it("treats git operation errors as functional changes to be conservative", async () => { const jsonFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; @@ -303,12 +407,11 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: true, - nonFunctional: false, other: false, }); }); - it("handles nested non-functional property changes", async () => { + it("correctly identifies non-functional changes in nested object properties", async () => { const jsonFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; @@ -319,6 +422,7 @@ describe("checkTrivialChanges", () => { "/test": { get: { summary: "Test endpoint", + description: "Gets test data", operationId: "Test_Get" } } @@ -335,7 +439,7 @@ describe("checkTrivialChanges", () => { "/test": { get: { summary: "Updated test endpoint", // Non-functional change - description: "Gets test data", // Non-functional change + description: "Gets test data", operationId: "Test_Get" } } @@ -367,12 +471,11 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: false, - nonFunctional: true, other: false, }); }); - it("detects functional changes in nested properties", async () => { + it("detects functional changes when parameters are added to API operations", async () => { const jsonFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" ]; @@ -429,12 +532,11 @@ describe("checkTrivialChanges", () => { documentation: false, examples: false, functional: true, - nonFunctional: false, other: false, }); }); - it("handles mixed documentation and examples correctly", async () => { + it("correctly identifies multiple trivial change types together (documentation + examples)", async () => { const mixedTrivialFiles = [ "specification/someservice/resource-manager/Microsoft.Service/README.md", "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", @@ -453,7 +555,6 @@ describe("checkTrivialChanges", () => { documentation: true, examples: true, functional: false, - nonFunctional: false, other: false, }); }); From 495559ef001287c733d5205aa00ba085c948a7c3 Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Tue, 9 Dec 2025 11:41:54 -0800 Subject: [PATCH 07/36] whitespace --- .github/workflows/arm-auto-signoff-status.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 186899c1742e..4384f8108de8 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -70,7 +70,7 @@ jobs: # issueNumber: number # } - id: get-label-action - name: ARM Auto SignOff + name: ARM Auto SignOff uses: actions/github-script@v7 with: script: | From 189eaa849c3abc66ffaa12555f2cbf0da0b2f20d Mon Sep 17 00:00:00 2001 From: akhilailla Date: Wed, 10 Dec 2025 11:09:55 -0600 Subject: [PATCH 08/36] minor changes based on comments --- .github/workflows/arm-auto-signoff-status.yaml | 3 ++- .../workflows/src/arm-auto-signoff/arm-auto-signoff-code.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index c3b4717f9f8e..8887fa405158 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -44,6 +44,7 @@ jobs: (github.event.action == 'labeled' || github.event.action == 'unlabeled') && (github.event.label.name == 'Approved-Suppression' || + github.event.label.name == 'ARMAutoSignedOff' || github.event.label.name == 'ARMAutoSignedOff-IncrementalTSP' || github.event.label.name == 'ARMAutoSignedOff-Trivial' || github.event.label.name == 'ARMReview' || @@ -74,7 +75,7 @@ jobs: # issueNumber: number # } - id: get-label-action - name: ARM Auto SignOff - Set Status + name: ARM Auto SignOff uses: actions/github-script@v7 with: script: | diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 826814160636..3f867bb8655a 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -1,4 +1,6 @@ import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; +import incrementalTypeSpec from './arm-incremental-typespec.js'; +import checkTrivialChanges from './trivial-changes-check.js'; /** * Main entry point for ARM Auto-SignOff Code workflow @@ -12,8 +14,6 @@ import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; * @returns {Promise} JSON string containing combined results */ export default async function armAutoSignOffCode({ github, context, core }) { - const { default: incrementalTypeSpec } = await import('./incremental-typespec.js'); - const { default: checkTrivialChanges } = await import('./trivial-changes-check.js'); core.info('Starting ARM Auto-SignOff Code analysis...'); From 99b3e9ceeee0c71130e98fe6f17c4de721a69b8d Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Wed, 10 Dec 2025 16:29:06 -0800 Subject: [PATCH 09/36] rename step --- .github/workflows/arm-auto-signoff-status.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 8887fa405158..9f7896a20c55 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -75,7 +75,7 @@ jobs: # issueNumber: number # } - id: get-label-action - name: ARM Auto SignOff + name: ARM Auto SignOff - Set Status uses: actions/github-script@v7 with: script: | From b55614c3c3a49da85cf88f7292548d8a3d86fe41 Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Wed, 10 Dec 2025 17:04:19 -0800 Subject: [PATCH 10/36] Revert workflow title --- .github/workflows/arm-auto-signoff-code.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index 074b64b8aef4..2b4e948965f8 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -1,4 +1,4 @@ -name: ARM Auto-SignOff - Analyze Code +name: ARM Auto SignOff - Analyze Code on: pull_request: From 0cd5fdd34093d13d3db70c0dd9474ffe0c5bba4c Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Wed, 10 Dec 2025 17:04:52 -0800 Subject: [PATCH 11/36] Revert workflow title --- .github/workflows/arm-auto-signoff-code.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index 2b4e948965f8..b51d432be4df 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -1,4 +1,4 @@ -name: ARM Auto SignOff - Analyze Code +name: ARM Auto Signoff - Analyze Code on: pull_request: From 6f96b9af493239ac467669be9c432d29d5aa0d49 Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Wed, 10 Dec 2025 17:05:28 -0800 Subject: [PATCH 12/36] revert step title --- .github/workflows/arm-auto-signoff-code.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index b51d432be4df..6817521f97a9 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -21,7 +21,7 @@ permissions: jobs: arm-auto-signoff-code: - name: ARM Auto-SignOff - Analyze Code + name: ARM Auto Signoff - Analyze Code runs-on: ubuntu-24.04 From dfc2e2befaabfd7251bea85985f4f9a16c267a4b Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 11 Dec 2025 10:54:27 -0600 Subject: [PATCH 13/36] Refcatoring based on comments --- .github/workflows/arm-auto-signoff-code.yaml | 16 +-- .../workflows/arm-auto-signoff-status.yaml | 20 +-- .../arm-auto-signoff/arm-auto-signoff-code.js | 16 +-- .../arm-auto-signoff-status.js | 24 ++-- .../src/arm-auto-signoff}/pr-changes.js | 0 .../trivial-changes-auto-signoff.md | 115 ++++++++++++++++++ .../arm-auto-signoff/trivial-changes-check.js | 5 +- .../trivial-changes-check.test.js | 6 +- 8 files changed, 158 insertions(+), 44 deletions(-) rename .github/{shared/src => workflows/src/arm-auto-signoff}/pr-changes.js (100%) create mode 100644 .github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md rename .github/workflows/test/{ => arm-auto-signoff}/trivial-changes-check.test.js (98%) diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index 074b64b8aef4..89648339532d 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -1,4 +1,4 @@ -name: ARM Auto-SignOff - Analyze Code +name: ARM Auto Signoff - Analyze Code on: pull_request: @@ -21,7 +21,7 @@ permissions: jobs: arm-auto-signoff-code: - name: ARM Auto-SignOff - Analyze Code + name: ARM Auto Signoff - Analyze Code runs-on: ubuntu-24.04 @@ -30,7 +30,7 @@ jobs: with: # Fetch full PR history including base branch for proper diff comparison fetch-depth: 0 - # Fetch base branch to compare against + # Checkout PR head commit ref: ${{ github.event.pull_request.head.sha }} # Check only needs to view contents of changed files as a string, so can use # "git show" on specific files instead of cloning whole repo @@ -47,19 +47,19 @@ jobs: install-command: "npm ci --no-audit --omit dev" working-directory: ./.github - # Run ARM Auto-SignOff Code analysis + # Run ARM Auto-Signoff Code analysis - id: arm-auto-signoff-code - name: ARM Auto-SignOff Code + name: ARM Auto Signoff Code Analysis uses: actions/github-script@v7 with: result-encoding: string script: | - const { default: armAutoSignOffCode } = + const { default: armAutoSignoffCode } = await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js'); - return await armAutoSignOffCode({ github, context, core }); + return await armAutoSignoffCode({ github, context, core }); # Upload artifact with all results - - name: Upload artifact - ARM Auto-SignOff Code Results + - name: Upload artifact - ARM Auto Signoff Code Results uses: ./.github/actions/add-empty-artifact with: name: "arm-auto-signoff-code-results" diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 8887fa405158..39eead224c83 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -1,4 +1,4 @@ -name: ARM Auto SignOff - Set Status +name: ARM Auto Signoff - Set Status on: # Must run on pull_request_target instead of pull_request, since the latter cannot trigger on @@ -34,7 +34,7 @@ permissions: jobs: arm-auto-signoff-status: - name: ARM Auto SignOff - Set Status + name: ARM Auto Signoff - Set Status # workflow_run - already filtered by triggers above # pull_request_target:labeled - filter to only the input and output labels @@ -75,7 +75,7 @@ jobs: # issueNumber: number # } - id: get-label-action - name: ARM Auto SignOff + name: ARM Auto Signoff uses: actions/github-script@v7 with: script: | @@ -84,27 +84,27 @@ jobs: return await getLabelAction({ github, context, core }); # Add/remove specific auto sign-off labels based on analysis results - # Only upload label artifacts if we have autoSignOffLabels defined (not undefined) - - if: fromJson(steps.get-label-action.outputs.result).autoSignOffLabels != null + # Only upload label artifacts if we have autoSignoffLabels defined (not undefined) + - if: fromJson(steps.get-label-action.outputs.result).autoSignoffLabels != null name: Upload artifact for ARMSignedOff label uses: ./.github/actions/add-label-artifact with: name: "ARMSignedOff" - value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignOffLabels && (contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-IncrementalTSP') || contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-Trivial')) }}" + value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignoffLabels && (contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-IncrementalTSP') || contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-Trivial')) }}" - - if: fromJson(steps.get-label-action.outputs.result).autoSignOffLabels != null + - if: fromJson(steps.get-label-action.outputs.result).autoSignoffLabels != null name: Upload artifact for ARMAutoSignedOff-IncrementalTSP label uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-IncrementalTSP" - value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignOffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-IncrementalTSP') }}" + value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignoffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-IncrementalTSP') }}" - - if: fromJson(steps.get-label-action.outputs.result).autoSignOffLabels != null + - if: fromJson(steps.get-label-action.outputs.result).autoSignoffLabels != null name: Upload artifact for ARMAutoSignedOff-Trivial label uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-Trivial" - value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignOffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignOffLabels, 'ARMAutoSignedOff-Trivial') }}" + value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignoffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-Trivial') }}" # Required for consumers to identify the head SHA associated with this workflow run. # Output can be trusted, because it was uploaded from a workflow that is trusted, diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 3f867bb8655a..f17dcabf9ddd 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -1,9 +1,9 @@ -import { isTrivialPullRequest } from "../../../shared/src/pr-changes.js"; +import { isTrivialPullRequest } from "./pr-changes.js import incrementalTypeSpec from './arm-incremental-typespec.js'; import checkTrivialChanges from './trivial-changes-check.js'; /** - * Main entry point for ARM Auto-SignOff Code workflow + * Main entry point for ARM Auto Signoff Code workflow * * This module orchestrates all checks to determine if a PR qualifies for auto sign-off. * It combines results from: @@ -13,9 +13,9 @@ import checkTrivialChanges from './trivial-changes-check.js'; * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments * @returns {Promise} JSON string containing combined results */ -export default async function armAutoSignOffCode({ github, context, core }) { +export default async function armAutoSignoffCode({ github, context, core }) { - core.info('Starting ARM Auto-SignOff Code analysis...'); + core.info('Starting ARM Auto Signoff Code analysis.'); // Run incremental TypeSpec check core.startGroup('Checking for incremental TypeSpec changes'); @@ -32,18 +32,18 @@ export default async function armAutoSignOffCode({ github, context, core }) { // Determine if PR qualifies for auto sign-off const isTrivial = isTrivialPullRequest(trivialChanges); - const qualifiesForAutoSignOff = incrementalTypeSpecResult || isTrivial; + const qualifiesForAutoSignoff = incrementalTypeSpecResult || isTrivial; // Combine results const combined = { incrementalTypeSpec: incrementalTypeSpecResult, trivialChanges: trivialChanges, - qualifiesForAutoSignOff: qualifiesForAutoSignOff + qualifiesForAutoSignoff: qualifiesForAutoSignoff }; core.info(`Combined result: ${JSON.stringify(combined, null, 2)}`); - core.info(`Qualifies for auto sign-off: ${combined.qualifiesForAutoSignOff}`); + core.info(`Qualifies for auto sign-off: ${combined.qualifiesForAutoSignoff}`); // Return key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true - return `incrementalTypeSpec-${incrementalTypeSpecResult},isTrivial-${isTrivial},qualifies-${qualifiesForAutoSignOff}`; + return `incrementalTypeSpec-${incrementalTypeSpecResult},isTrivial-${isTrivial},qualifies-${qualifiesForAutoSignoff}`; } diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 201291c97b0a..680850387fc6 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -8,7 +8,7 @@ import { extractInputs } from "../context.js"; /* v8 ignore start */ /** * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments - * @returns {Promise<{headSha: string, issueNumber: number, autoSignOffLabels?: string[]}>} + * @returns {Promise<{headSha: string, issueNumber: number, autoSignoffLabels?: string[]}>} */ export default async function getLabelAction({ github, context, core }) { const { owner, repo, issue_number, head_sha } = await extractInputs(github, context, core); @@ -32,7 +32,7 @@ export default async function getLabelAction({ github, context, core }) { * @param {string} params.head_sha * @param {(import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api & { paginate: import("@octokit/plugin-paginate-rest").PaginateInterface; })} params.github * @param {typeof import("@actions/core")} params.core - * @returns {Promise<{headSha: string, issueNumber: number, autoSignOffLabels?: string[]}>} + * @returns {Promise<{headSha: string, issueNumber: number, autoSignoffLabels?: string[]}>} */ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, github, core }) { // TODO: Try to extract labels from context (when available) to avoid unnecessary API call @@ -77,12 +77,12 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { headSha: head_sha, issueNumber: issue_number, - autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before }; } // Store the labels for auto sign-off - const autoSignOffLabels = armAnalysisResult.labelsToAdd || []; + const autoSignoffLabels = armAnalysisResult.labelsToAdd || []; const allLabelsMatch = labelNames.includes("ARMReview") && @@ -95,7 +95,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { headSha: head_sha, issueNumber: issue_number, - autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before }; } @@ -140,7 +140,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { headSha: head_sha, issueNumber: issue_number, - autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before }; } @@ -160,7 +160,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { headSha: head_sha, issueNumber: issue_number, - autoSignOffLabels: autoSignOffLabels, + autoSignoffLabels: autoSignoffLabels, }; } @@ -170,7 +170,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { headSha: head_sha, issueNumber: issue_number, - autoSignOffLabels: hasAutoSignedOffLabels ? [] : undefined, // undefined means don't touch labels + autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // undefined means don't touch labels }; } @@ -184,7 +184,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, * @returns {Promise<{shouldAutoSign: boolean, reason: string, labelsToAdd?: string[]}>} */ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) { - const wfName = "ARM Auto-SignOff - Analyze Code"; + const wfName = "ARM Auto-Signoff - Analyze Code"; const armAnalysisRuns = workflowRuns .filter((wf) => wf.name == wfName) // Sort by "updated_at" descending @@ -233,11 +233,11 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) // Extract values directly by key name const incrementalTypeSpec = keyValuePairs[0]?.endsWith('-true') ?? false; const isTrivial = keyValuePairs[1]?.endsWith('-true') ?? false; - const qualifiesForAutoSignOff = keyValuePairs[2]?.endsWith('-true') ?? false; + const qualifiesForAutoSignoff = keyValuePairs[2]?.endsWith('-true') ?? false; - core.info(`ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}, qualifiesForAutoSignOff=${qualifiesForAutoSignOff}`); + core.info(`ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}, qualifiesForAutoSignoff=${qualifiesForAutoSignoff}`); - if (qualifiesForAutoSignOff) { + if (qualifiesForAutoSignoff) { const labelsToAdd = []; if (incrementalTypeSpec) { diff --git a/.github/shared/src/pr-changes.js b/.github/workflows/src/arm-auto-signoff/pr-changes.js similarity index 100% rename from .github/shared/src/pr-changes.js rename to .github/workflows/src/arm-auto-signoff/pr-changes.js diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md b/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md new file mode 100644 index 000000000000..88e651426367 --- /dev/null +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md @@ -0,0 +1,115 @@ +# Integrated Trivial Changes Auto Signoff + +This document describes the integrated trivial changes detection functionality within the ARM Incremental TypeSpec +workflow that extends auto signoff capabilities. + +## Overview + +The ARM Incremental TypeSpec workflow now includes built-in detection for three types of trivial changes that can +qualify for auto sign-off: + +1. **Documentation Only Changes** - PRs that only modify `.md` files +2. **Examples Only Changes** - PRs that only modify files in `/examples/` folders +3. **Non-functional Changes** - PRs that only modify non-functional properties in JSON files + +## Workflow Details + +### ARM Incremental TypeSpec Workflow (`arm-incremental-typespec.yaml`) + +This workflow has been enhanced to run both the original ARM incremental TypeSpec analysis and the new trivial +changes detection. It produces: + +1. **Legacy artifact** - `incremental-typespec=true/false` (for backward compatibility) +2. **Combined artifact** - `arm-analysis-results` containing a JSON structure: + + ```json + { + "incrementalTypeSpec": true/false, + "trivialChanges": { + "documentationOnlyChanges": true/false, + "examplesOnlyChanges": true/false, + "nonFunctionalChanges": true/false, + "anyTrivialChanges": true/false + }, + "qualifiesForAutoSignOff": true/false + } + ``` + +### Non-functional Properties + +The following JSON properties are considered non-functional (safe to change without affecting API behavior): + +- `description`, `title`, `summary` +- `x-ms-summary`, `x-ms-description` +- `externalDocs` +- `x-ms-examples`, `x-example`, `example`, `examples` +- `x-ms-client-name`, `x-ms-parameter-name` +- `x-ms-enum`, `x-ms-discriminator-value` +- `x-ms-client-flatten`, `x-ms-azure-resource` +- `x-ms-mutability`, `x-ms-secret` +- `x-ms-parameter-location`, `x-ms-skip-url-encoding` +- `x-ms-long-running-operation`, `x-ms-long-running-operation-options` +- `x-ms-pageable`, `x-ms-odata` +- `tags`, `x-ms-api-annotation` + +### ARM Auto Sign-off Integration + +The ARM auto sign-off workflow consumes the combined artifact from the ARM Incremental TypeSpec workflow. +Auto sign-off is applied if: + +1. The PR contains incremental TypeSpec changes, OR +2. The PR contains any qualifying trivial changes + +All other requirements must still be met (proper labels, passing status checks, etc.). + +### Labels + +When trivial changes are detected and auto sign-off is applied, the following labels may be added: + +- `DocumentationOnlyChanges` - For documentation-only PRs +- `ExamplesOnlyChanges` - For examples-only PRs +- `NonFunctionalChanges` - For non-functional JSON changes + +## Files Changed + +### Modified Files + +- `.github/workflows/arm-incremental-typespec.yaml` - Enhanced with trivial changes detection +- `.github/workflows/src/arm-auto-signoff.js` - Updated to handle combined analysis results +- `.github/workflows/arm-auto-signoff.yaml` - Updated label handling for new artifact format + +### New Files + +- `.github/workflows/src/trivial-changes-check.js` - Trivial changes detection logic +- `.github/workflows/test/trivial-changes-check.test.js` - Basic tests + +## Configuration + +No additional configuration is required. The workflow automatically runs on all PRs and detects trivial changes +based on the predefined criteria. + +## Testing + +To test the functionality: + +1. Create a PR with only documentation changes (`.md` files) +2. Create a PR with only example changes (files in `/examples/` folders) +3. Create a PR with only non-functional JSON property changes +4. Verify that the appropriate labels are applied and auto sign-off occurs + +## Limitations + +- The non-functional changes detection is conservative and may not catch all possible non-functional changes +- New JSON files are currently treated as functional changes for safety +- Array changes in JSON are treated conservatively and may be flagged as functional even if they're not +- The workflow requires all other auto sign-off requirements to be met (proper labels, passing checks, etc.) + +## Future Improvements + +Potential enhancements: + +1. More sophisticated JSON diff analysis +2. Support for additional file types +3. Configurable non-functional property lists +4. Better handling of complex JSON structure changes +5. Integration with OpenAPI/Swagger semantic analysis tools diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 8f31b2e50ff2..42adbc9f9233 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -14,8 +14,7 @@ import { isTrivialPullRequest, isDocumentationOnly, isExamplesOnly, - isNonFunctionalOnly, -} from "../../../shared/src/pr-changes.js"; +} from "./pr-changes.js"; import { CoreLogger } from "../core-logger.js"; // Enable simple-git debug logging to improve console output @@ -156,7 +155,7 @@ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { * @param {import('simple-git').SimpleGit} git - Git instance * @param {typeof import("@actions/core")} core - Core logger * @param {string} baseRef - The base branch reference (e.g., "origin/main") - * @param {import('../../../shared/src/pr-changes.js').PullRequestChanges} changes - Changes object to update + * @param {import('./pr-changes.js').PullRequestChanges} changes - Changes object to update * @returns {Promise} */ async function analyzePullRequestChanges(changedFiles, git, core, baseRef, changes) { diff --git a/.github/workflows/test/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js similarity index 98% rename from .github/workflows/test/trivial-changes-check.test.js rename to .github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index be2f7004e216..d125fc42b627 100644 --- a/.github/workflows/test/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -7,9 +7,9 @@ vi.mock("simple-git", () => ({ })); import * as simpleGit from "simple-git"; -import * as changedFiles from "../../shared/src/changed-files.js"; -import checkTrivialChanges from "../src/arm-auto-signoff/trivial-changes-check.js"; -import { createMockCore, createMockContext } from "./mocks.js"; +import * as changedFiles from "../../../shared/src/changed-files.js"; +import checkTrivialChanges from "../../src/arm-auto-signoff/trivial-changes-check.js"; +import { createMockCore, createMockContext } from "../mocks.js"; const core = createMockCore(); const context = createMockContext(); From 7a97f5daf89690a8149d273f148c32afbf4d81b9 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 11 Dec 2025 13:21:54 -0600 Subject: [PATCH 14/36] fix missing token error --- .github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index f17dcabf9ddd..182352c802ad 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -1,4 +1,4 @@ -import { isTrivialPullRequest } from "./pr-changes.js +import { isTrivialPullRequest } from "./pr-changes.js"; import incrementalTypeSpec from './arm-incremental-typespec.js'; import checkTrivialChanges from './trivial-changes-check.js'; From 072d8240dd4120701623e6e6cd67fca147979e1a Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Thu, 11 Dec 2025 11:53:21 -0800 Subject: [PATCH 15/36] resolve merge conflict --- .github/shared/src/changed-files.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/shared/src/changed-files.js b/.github/shared/src/changed-files.js index e69c327cab1f..5f2116a29177 100644 --- a/.github/shared/src/changed-files.js +++ b/.github/shared/src/changed-files.js @@ -188,15 +188,6 @@ export function readme(file) { return typeof file === "string" && file.toLowerCase().endsWith("readme.md"); } -/** - * @param {string} [file] - * @returns {boolean} - */ -export function markdown(file) { - // Extension ".md" with any case is a valid markdown file - return typeof file === "string" && file.toLowerCase().endsWith(".md"); -} - /** * @param {string} [file] * @returns {boolean} From c653cde49a9b041456c32adb8d7549dac07a5c06 Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Thu, 11 Dec 2025 11:53:46 -0800 Subject: [PATCH 16/36] Delete documentation/trivial-changes-auto-signoff.md --- documentation/trivial-changes-auto-signoff.md | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 documentation/trivial-changes-auto-signoff.md diff --git a/documentation/trivial-changes-auto-signoff.md b/documentation/trivial-changes-auto-signoff.md deleted file mode 100644 index 16a3ab4d4073..000000000000 --- a/documentation/trivial-changes-auto-signoff.md +++ /dev/null @@ -1,115 +0,0 @@ -# Integrated Trivial Changes Auto Sign-off - -This document describes the integrated trivial changes detection functionality within the ARM Incremental TypeSpec -workflow that extends auto sign-off capabilities. - -## Overview - -The ARM Incremental TypeSpec workflow now includes built-in detection for three types of trivial changes that can -qualify for auto sign-off: - -1. **Documentation Only Changes** - PRs that only modify `.md` files -2. **Examples Only Changes** - PRs that only modify files in `/examples/` folders -3. **Non-functional Changes** - PRs that only modify non-functional properties in JSON files - -## Workflow Details - -### ARM Incremental TypeSpec Workflow (`arm-incremental-typespec.yaml`) - -This workflow has been enhanced to run both the original ARM incremental TypeSpec analysis and the new trivial -changes detection. It produces: - -1. **Legacy artifact** - `incremental-typespec=true/false` (for backward compatibility) -2. **Combined artifact** - `arm-analysis-results` containing a JSON structure: - - ```json - { - "incrementalTypeSpec": true/false, - "trivialChanges": { - "documentationOnlyChanges": true/false, - "examplesOnlyChanges": true/false, - "nonFunctionalChanges": true/false, - "anyTrivialChanges": true/false - }, - "qualifiesForAutoSignOff": true/false - } - ``` - -### Non-functional Properties - -The following JSON properties are considered non-functional (safe to change without affecting API behavior): - -- `description`, `title`, `summary` -- `x-ms-summary`, `x-ms-description` -- `externalDocs` -- `x-ms-examples`, `x-example`, `example`, `examples` -- `x-ms-client-name`, `x-ms-parameter-name` -- `x-ms-enum`, `x-ms-discriminator-value` -- `x-ms-client-flatten`, `x-ms-azure-resource` -- `x-ms-mutability`, `x-ms-secret` -- `x-ms-parameter-location`, `x-ms-skip-url-encoding` -- `x-ms-long-running-operation`, `x-ms-long-running-operation-options` -- `x-ms-pageable`, `x-ms-odata` -- `tags`, `x-ms-api-annotation` - -### ARM Auto Sign-off Integration - -The ARM auto sign-off workflow consumes the combined artifact from the ARM Incremental TypeSpec workflow. -Auto sign-off is applied if: - -1. The PR contains incremental TypeSpec changes, OR -2. The PR contains any qualifying trivial changes - -All other requirements must still be met (proper labels, passing status checks, etc.). - -### Labels - -When trivial changes are detected and auto sign-off is applied, the following labels may be added: - -- `DocumentationOnlyChanges` - For documentation-only PRs -- `ExamplesOnlyChanges` - For examples-only PRs -- `NonFunctionalChanges` - For non-functional JSON changes - -## Files Changed - -### Modified Files - -- `.github/workflows/arm-incremental-typespec.yaml` - Enhanced with trivial changes detection -- `.github/workflows/src/arm-auto-signoff.js` - Updated to handle combined analysis results -- `.github/workflows/arm-auto-signoff.yaml` - Updated label handling for new artifact format - -### New Files - -- `.github/workflows/src/trivial-changes-check.js` - Trivial changes detection logic -- `.github/workflows/test/trivial-changes-check.test.js` - Basic tests - -## Configuration - -No additional configuration is required. The workflow automatically runs on all PRs and detects trivial changes -based on the predefined criteria. - -## Testing - -To test the functionality: - -1. Create a PR with only documentation changes (`.md` files) -2. Create a PR with only example changes (files in `/examples/` folders) -3. Create a PR with only non-functional JSON property changes -4. Verify that the appropriate labels are applied and auto sign-off occurs - -## Limitations - -- The non-functional changes detection is conservative and may not catch all possible non-functional changes -- New JSON files are currently treated as functional changes for safety -- Array changes in JSON are treated conservatively and may be flagged as functional even if they're not -- The workflow requires all other auto sign-off requirements to be met (proper labels, passing checks, etc.) - -## Future Improvements - -Potential enhancements: - -1. More sophisticated JSON diff analysis -2. Support for additional file types -3. Configurable non-functional property lists -4. Better handling of complex JSON structure changes -5. Integration with OpenAPI/Swagger semantic analysis tools From 9c07141361b0a65d2c0007e57980c6d3766d1d20 Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Thu, 11 Dec 2025 12:07:26 -0800 Subject: [PATCH 17/36] remove unhelpful comment --- .github/workflows/arm-auto-signoff-code.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index 9373c773b173..471b55d4e159 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -47,7 +47,6 @@ jobs: install-command: "npm ci --no-audit --omit dev" working-directory: ./.github - # Run ARM Auto-Signoff Code analysis - id: arm-auto-signoff-code name: ARM Auto SignOff - Analyze Code uses: actions/github-script@v7 From 55e2999802c0511cbaeda5b746abcd7ee5a8ece5 Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Thu, 11 Dec 2025 12:09:27 -0800 Subject: [PATCH 18/36] revert names --- .github/workflows/arm-auto-signoff-status.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 30e8678a9956..ff96ef9914f9 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -1,4 +1,4 @@ -name: ARM Auto Signoff - Set Status +name: ARM Auto SignOff - Set Status on: # Must run on pull_request_target instead of pull_request, since the latter cannot trigger on @@ -34,7 +34,7 @@ permissions: jobs: arm-auto-signoff-status: - name: ARM Auto Signoff - Set Status + name: ARM Auto SignOff - Set Status # workflow_run - already filtered by triggers above # pull_request_target:labeled - filter to only the input and output labels @@ -75,7 +75,7 @@ jobs: # issueNumber: number # } - id: get-label-action - name: ARM Auto Signoff + name: ARM Auto SignOff - Set Status uses: actions/github-script@v7 with: script: | From 839b12ee600a2a0502e53f2db8a5ff0c47033a4a Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Thu, 11 Dec 2025 14:29:43 -0800 Subject: [PATCH 19/36] whitespace --- .github/workflows/src/arm-auto-signoff/trivial-changes-check.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 42adbc9f9233..7361b855db9b 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -382,4 +382,4 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { function isNonFunctionalArrayChange(baseArray, headArray, path) { const currentProperty = path.split('.').pop() || path; return NON_FUNCTIONAL_PROPERTIES.has(currentProperty); -} \ No newline at end of file +} From f1daaf876212be5cc0fb7c7723d6aefc19080a7f Mon Sep 17 00:00:00 2001 From: akhilailla Date: Tue, 16 Dec 2025 10:00:14 -0600 Subject: [PATCH 20/36] ARM auto-signoff: trivial changes + managed label actions --- .github/shared/test/exec.test.js | 6 +- .github/workflows/arm-auto-signoff-code.yaml | 7 +- .../workflows/arm-auto-signoff-status.yaml | 40 +- .../arm-auto-signoff/arm-auto-signoff-code.js | 57 ++- .../arm-auto-signoff-status.js | 389 ++++++++++---- .../arm-incremental-typespec.js | 5 +- .../src/arm-auto-signoff/pr-changes.js | 18 +- .../arm-auto-signoff/trivial-changes-check.js | 236 ++++++--- .../arm-auto-signoff-status.test.js | 134 ++++- .../trivial-changes-check.test.js | 481 ++++++++++-------- 10 files changed, 906 insertions(+), 467 deletions(-) diff --git a/.github/shared/test/exec.test.js b/.github/shared/test/exec.test.js index 87cc88635450..7b4789732cc8 100644 --- a/.github/shared/test/exec.test.js +++ b/.github/shared/test/exec.test.js @@ -40,7 +40,7 @@ describe("execNpm", () => { stdout: /** @type {unknown} */ (expect.toSatisfy((v) => semver.valid(String(v)) !== null)), stderr: "", }); - }); + }, 120000); it("fails with --help", async () => { await expect(execNpm(["--help"], options)).rejects.toMatchObject({ @@ -48,7 +48,7 @@ describe("execNpm", () => { stderr: "", code: 1, }); - }); + }, 120000); }); describe("execNpmExec", () => { @@ -61,7 +61,7 @@ describe("execNpmExec", () => { stderr: "", error: undefined, }); - }); + }, 120000); }); describe("isExecError", () => { diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index 471b55d4e159..3d421d82640a 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -28,10 +28,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Fetch full PR history including base branch for proper diff comparison - fetch-depth: 0 - # Checkout PR head commit - ref: ${{ github.event.pull_request.head.sha }} + # We compare HEAD (PR merge commit) against HEAD^ (base) via git diff, + # so we only need enough history to include the merge commit and its parent. + fetch-depth: 2 # Check only needs to view contents of changed files as a string, so can use # "git show" on specific files instead of cloning whole repo sparse-checkout: | diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index ff96ef9914f9..af77f5fe90fa 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -36,6 +36,11 @@ jobs: arm-auto-signoff-status: name: ARM Auto SignOff - Set Status + env: + # When enabled, trivial auto-signoff is routed to the "-Test" label and + # ARMSignedOff is only auto-managed for IncrementalTSP. + ARM_AUTO_SIGNOFF_TRIVIAL_DRYRUN: "true" + # workflow_run - already filtered by triggers above # pull_request_target:labeled - filter to only the input and output labels if: | @@ -46,7 +51,7 @@ jobs: (github.event.label.name == 'Approved-Suppression' || github.event.label.name == 'ARMAutoSignedOff' || github.event.label.name == 'ARMAutoSignedOff-IncrementalTSP' || - github.event.label.name == 'ARMAutoSignedOff-Trivial' || + github.event.label.name == 'ARMAutoSignedOff-Trivial-Test' || github.event.label.name == 'ARMReview' || github.event.label.name == 'ARMSignedOff' || github.event.label.name == 'NotReadyForARMReview' || @@ -71,8 +76,13 @@ jobs: # Output: # { - # labelAction: LabelAction, ("none" for no-op, "add" to add label, and "remove" to remove label) - # issueNumber: number + # headSha: string, + # issueNumber: number, + # labelActions: { + # 'ARMSignedOff': 'none'|'add'|'remove', + # 'ARMAutoSignedOff-IncrementalTSP': 'none'|'add'|'remove', + # 'ARMAutoSignedOff-Trivial-Test': 'none'|'add'|'remove' + # } # } - id: get-label-action name: ARM Auto SignOff - Set Status @@ -83,28 +93,28 @@ jobs: await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js'); return await getLabelAction({ github, context, core }); - # Add/remove specific auto sign-off labels based on analysis results - # Only upload label artifacts if we have autoSignoffLabels defined (not undefined) - - if: fromJson(steps.get-label-action.outputs.result).autoSignoffLabels != null - name: Upload artifact for ARMSignedOff label + # Add/remove specific auto sign-off labels based on analysis results. + # The action is explicit: 'add' | 'remove' | 'none'. + - name: Upload artifact for ARMSignedOff label + if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMSignedOff'] != 'none' uses: ./.github/actions/add-label-artifact with: name: "ARMSignedOff" - value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignoffLabels && (contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-IncrementalTSP') || contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-Trivial')) }}" + value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMSignedOff'] == 'add' }}" - - if: fromJson(steps.get-label-action.outputs.result).autoSignoffLabels != null - name: Upload artifact for ARMAutoSignedOff-IncrementalTSP label + - name: Upload artifact for ARMAutoSignedOff-IncrementalTSP label + if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] != 'none' uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-IncrementalTSP" - value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignoffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-IncrementalTSP') }}" + value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] == 'add' }}" - - if: fromJson(steps.get-label-action.outputs.result).autoSignoffLabels != null - name: Upload artifact for ARMAutoSignedOff-Trivial label + - name: Upload artifact for ARMAutoSignedOff-Trivial-Test label + if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] != 'none' uses: ./.github/actions/add-label-artifact with: - name: "ARMAutoSignedOff-Trivial" - value: "${{ fromJson(steps.get-label-action.outputs.result).autoSignoffLabels && contains(fromJson(steps.get-label-action.outputs.result).autoSignoffLabels, 'ARMAutoSignedOff-Trivial') }}" + name: "ARMAutoSignedOff-Trivial-Test" + value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] == 'add' }}" # Required for consumers to identify the head SHA associated with this workflow run. # Output can be trusted, because it was uploaded from a workflow that is trusted, diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 182352c802ad..5a74c7ea4e1a 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -1,32 +1,59 @@ +import incrementalTypeSpec from "./arm-incremental-typespec.js"; import { isTrivialPullRequest } from "./pr-changes.js"; -import incrementalTypeSpec from './arm-incremental-typespec.js'; -import checkTrivialChanges from './trivial-changes-check.js'; +import checkTrivialChanges from "./trivial-changes-check.js"; + +/** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ + +/** + * @param {unknown} value + * @returns {value is PullRequestChanges} + */ +function isPullRequestChanges(value) { + if (typeof value !== "object" || value === null) { + return false; + } + + /** @type {Record} */ + const obj = /** @type {Record} */ (value); + + return ( + typeof obj.documentation === "boolean" && + typeof obj.examples === "boolean" && + typeof obj.functional === "boolean" && + typeof obj.other === "boolean" + ); +} /** - * Main entry point for ARM Auto Signoff Code workflow - * + * Main entry point for ARM Auto Signoff Code workflow. + * * This module orchestrates all checks to determine if a PR qualifies for auto sign-off. * It combines results from: * - Incremental TypeSpec check * - Trivial changes check - * - * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments - * @returns {Promise} JSON string containing combined results + * + * @param {import("@actions/github-script").AsyncFunctionArguments} args + * @returns {Promise} Key-value format string consumed by downstream workflow logic. */ -export default async function armAutoSignoffCode({ github, context, core }) { +export default async function armAutoSignoffCode(args) { + const { context, core } = args; - core.info('Starting ARM Auto Signoff Code analysis.'); + core.info("Starting ARM Auto Signoff Code analysis."); // Run incremental TypeSpec check - core.startGroup('Checking for incremental TypeSpec changes'); - const incrementalTypeSpecResult = await incrementalTypeSpec({ github, context, core }); + core.startGroup("Checking for incremental TypeSpec changes"); + const incrementalTypeSpecResult = await incrementalTypeSpec({ core }); core.info(`Incremental TypeSpec result: ${incrementalTypeSpecResult}`); core.endGroup(); // Run trivial changes check - core.startGroup('Checking for trivial changes'); - const trivialChangesResultString = await checkTrivialChanges({ github, context, core }); - const trivialChanges = JSON.parse(trivialChangesResultString); + core.startGroup("Checking for trivial changes"); + const trivialChangesResultString = await checkTrivialChanges({ core, context }); + const parsedTrivialChanges = /** @type {unknown} */ (JSON.parse(trivialChangesResultString)); + if (!isPullRequestChanges(parsedTrivialChanges)) { + throw new Error("Unexpected trivial changes result shape"); + } + const trivialChanges = parsedTrivialChanges; core.info(`Trivial changes result: ${JSON.stringify(trivialChanges)}`); core.endGroup(); @@ -38,7 +65,7 @@ export default async function armAutoSignoffCode({ github, context, core }) { const combined = { incrementalTypeSpec: incrementalTypeSpecResult, trivialChanges: trivialChanges, - qualifiesForAutoSignoff: qualifiesForAutoSignoff + qualifiesForAutoSignoff: qualifiesForAutoSignoff, }; core.info(`Combined result: ${JSON.stringify(combined, null, 2)}`); diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 07d7f2dad0ad..013cb99bda99 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -3,12 +3,110 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../../shared/src/github.js"; import { equals } from "../../../shared/src/set.js"; import { byDate, invert } from "../../../shared/src/sort.js"; import { extractInputs } from "../context.js"; +import { LabelAction } from "../label.js"; + +const LABEL_ARM_SIGNED_OFF = "ARMSignedOff"; +const LABEL_ARM_AUTO_SIGNED_OFF = "ARMAutoSignedOff"; +const LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP = "ARMAutoSignedOff-IncrementalTSP"; +const LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST = "ARMAutoSignedOff-Trivial-Test"; + +const ENV_TRIVIAL_DRYRUN = "ARM_AUTO_SIGNOFF_TRIVIAL_DRYRUN"; + +/** + * The workflow contract is intentionally a fixed set of keys. + * @typedef {{ + * "ARMSignedOff": LabelAction, + * "ARMAutoSignedOff-IncrementalTSP": LabelAction, + * "ARMAutoSignedOff-Trivial-Test": LabelAction, + * }} ManagedLabelActions + */ + +/** @returns {ManagedLabelActions} */ +function createNoneLabelActions() { + return { + [LABEL_ARM_SIGNED_OFF]: LabelAction.None, + [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: LabelAction.None, + [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: LabelAction.None, + }; +} + +/** @typedef {{ name: string }} IssueLabel */ + +/** + * Minimal subset of workflow run fields used by this workflow. + * @typedef {{ + * id: number, + * name: string, + * status: string, + * conclusion: string | null, + * updated_at: string, + * }} WorkflowRun + */ + +/** + * Minimal subset of commit status fields used by this workflow. + * @typedef {{ + * context: string, + * state: string, + * updated_at: string, + * }} CommitStatus + */ + +/** @typedef {{ name: string }} Artifact */ + +/** + * @param {typeof import("@actions/core")} core + * @returns {boolean} + */ +function isTrivialDryRunEnabled(core) { + const raw = process.env[ENV_TRIVIAL_DRYRUN]; + const value = (raw ?? "").trim().toLowerCase(); + const enabled = value === "true"; + core.info(`${ENV_TRIVIAL_DRYRUN}=${raw ?? ""} (enabled=${enabled})`); + return enabled; +} + +/** + * @param {string} artifactName + * @returns {{ incrementalTypeSpec: boolean, isTrivial: boolean, qualifiesForAutoSignoff: boolean } | null} + */ +function parseArmAnalysisArtifact(artifactName) { + const prefix = "arm-auto-signoff-code-results="; + if (!artifactName.startsWith(prefix)) { + return null; + } + + const value = artifactName.substring(prefix.length); + + // Parse key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true + // Split by comma, then parse each key-value pair. + /** @type {Record} */ + const pairs = Object.fromEntries( + value + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + const lastDash = p.lastIndexOf("-"); + if (lastDash === -1) { + return [p, ""]; // will be treated as false + } + return [p.substring(0, lastDash), p.substring(lastDash + 1)]; + }), + ); + + return { + incrementalTypeSpec: pairs.incrementalTypeSpec === "true", + isTrivial: pairs.isTrivial === "true", + qualifiesForAutoSignoff: pairs.qualifies === "true", + }; +} // TODO: Add tests /* v8 ignore start */ /** * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments - * @returns {Promise<{headSha: string, issueNumber: number, autoSignoffLabels?: string[]}>} + * @returns {Promise<{headSha: string, issueNumber: number, labelActions: ManagedLabelActions}>} */ export default async function getLabelAction({ github, context, core }) { const { owner, repo, issue_number, head_sha } = await extractInputs(github, context, core); @@ -32,57 +130,85 @@ export default async function getLabelAction({ github, context, core }) { * @param {string} params.head_sha * @param {(import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api & { paginate: import("@octokit/plugin-paginate-rest").PaginateInterface; })} params.github * @param {typeof import("@actions/core")} params.core - * @returns {Promise<{headSha: string, issueNumber: number, autoSignoffLabels?: string[]}>} + * @returns {Promise<{headSha: string, issueNumber: number, labelActions: ManagedLabelActions}>} */ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, github, core }) { + const trivialDryRun = isTrivialDryRunEnabled(core); + // TODO: Try to extract labels from context (when available) to avoid unnecessary API call // permissions: { issues: read, pull-requests: read } - const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { - owner: owner, - repo: repo, - issue_number: issue_number, - per_page: PER_PAGE_MAX, - }); + const labels = /** @type {IssueLabel[]} */ ( + await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: owner, + repo: repo, + issue_number: issue_number, + per_page: PER_PAGE_MAX, + }) + ); const labelNames = labels.map((label) => label.name); + /** @type {{headSha: string, issueNumber: number}} */ + const baseResult = { + headSha: head_sha, + issueNumber: issue_number, + }; + + const labelActions = createNoneLabelActions(); + // Check if any auto sign-off labels are currently present // Only proceed with auto sign-off logic if auto labels exist or we're about to add them - const hasAutoSignedOffLabels = labelNames.some(name => - name === "ARMAutoSignedOff-Trivial" || - name === "ARMAutoSignedOff-IncrementalTSP" - ); + const hasAutoSignedOffLabels = + labelNames.includes(LABEL_ARM_AUTO_SIGNED_OFF) || + labelNames.includes(LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP); core.info(`Labels: ${inspect(labelNames)}`); core.info(`Has auto signed-off labels: ${hasAutoSignedOffLabels}`); // permissions: { actions: read } - const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { - owner, - repo, - event: "pull_request", - head_sha, - per_page: PER_PAGE_MAX, - }); + const workflowRuns = /** @type {WorkflowRun[]} */ ( + await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { + owner, + repo, + event: "pull_request", + head_sha, + per_page: PER_PAGE_MAX, + }) + ); core.info("Workflow Runs:"); workflowRuns.forEach((wf) => { core.info(`- ${wf.name}: ${wf.conclusion || wf.status}`); }); - // Check ARM Incremental TypeSpec workflow (which now includes trivial changes) + // Check ARM Auto SignOff - Analyze Code workflow results const armAnalysisResult = await checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core); - // If workflow indicates auto-signoff should not be applied - if (!armAnalysisResult.shouldAutoSign) { + const noneResult = { + ...baseResult, + labelActions, + }; + + const removeAutoSignedOffLabelsIfPresent = () => { + if (!hasAutoSignedOffLabels) { + return noneResult; + } + + // Only remove ARMSignedOff if it was auto-managed. return { - headSha: head_sha, - issueNumber: issue_number, - autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before + ...noneResult, + labelActions: { + ...labelActions, + [LABEL_ARM_SIGNED_OFF]: LabelAction.Remove, + [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: LabelAction.Remove, + [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: LabelAction.Remove, + }, }; - } + }; - // Store the labels for auto sign-off - const autoSignoffLabels = armAnalysisResult.labelsToAdd || []; + // If workflow indicates auto-signoff should not be applied + if (!armAnalysisResult.qualifiesForAutoSignoff) { + return removeAutoSignedOffLabelsIfPresent(); + } const allLabelsMatch = labelNames.includes("ARMReview") && @@ -92,20 +218,18 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, if (!allLabelsMatch) { core.info("Labels do not meet requirement for auto-signoff"); - return { - headSha: head_sha, - issueNumber: issue_number, - autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before - }; + return removeAutoSignedOffLabelsIfPresent(); } // permissions: { statuses: read } - const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { - owner: owner, - repo: repo, - ref: head_sha, - per_page: PER_PAGE_MAX, - }); + const statuses = /** @type {CommitStatus[]} */ ( + await github.paginate(github.rest.repos.listCommitStatusesForRef, { + owner: owner, + repo: repo, + ref: head_sha, + per_page: PER_PAGE_MAX, + }) + ); core.info("Statuses:"); statuses.forEach((status) => { @@ -114,9 +238,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, const requiredStatusNames = ["Swagger LintDiff", "Swagger Avocado"]; - /** - * @type {typeof statuses} - */ + /** @type {CommitStatus[]} */ let requiredStatuses = []; for (const statusName of requiredStatusNames) { @@ -137,11 +259,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, matchingStatus.state === CommitStatusState.FAILURE) ) { core.info(`Status '${matchingStatus.context}' did not succeed`); - return { - headSha: head_sha, - issueNumber: issue_number, - autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // Only remove if we had auto labels before - }; + return removeAutoSignedOffLabelsIfPresent(); } if (matchingStatus) { @@ -157,31 +275,52 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, requiredStatuses.every((status) => status.state === CommitStatusState.SUCCESS) ) { core.info("All requirements met for auto-signoff"); + + const autoIncremental = armAnalysisResult.incrementalTypeSpec; + const autoTrivialTest = trivialDryRun && armAnalysisResult.isTrivial; + + // Trivial auto-signoff is supported ONLY in dryrun/test mode. + // Outside of dryrun, trivial results are ignored. + if (!autoIncremental && !autoTrivialTest) { + return removeAutoSignedOffLabelsIfPresent(); + } + + const testTrivialAction = autoTrivialTest ? LabelAction.Add : LabelAction.Remove; + + // Keep labels in sync with current analysis results. + // When a label is not desired, emit Remove so it gets cleaned up if previously set. return { - headSha: head_sha, - issueNumber: issue_number, - autoSignoffLabels: autoSignoffLabels, + ...baseResult, + labelActions: { + ...labelActions, + [LABEL_ARM_SIGNED_OFF]: autoIncremental + ? LabelAction.Add + : hasAutoSignedOffLabels + ? LabelAction.Remove + : LabelAction.None, + [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: autoIncremental + ? LabelAction.Add + : LabelAction.Remove, + [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: testTrivialAction, + }, }; } // If any statuses are missing or pending, return empty labels only if we had auto labels before // This prevents removing manually-added ARMSignedOff labels core.info("One or more statuses are still pending"); - return { - headSha: head_sha, - issueNumber: issue_number, - autoSignoffLabels: hasAutoSignedOffLabels ? [] : undefined, // undefined means don't touch labels - }; + return removeAutoSignedOffLabelsIfPresent(); } /** - * Check ARM Analysis workflow results (combines incremental TypeSpec and trivial changes) - * @param {Array} workflowRuns - Array of workflow runs - * @param {any} github - GitHub client - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {any} core - Core logger - * @returns {Promise<{shouldAutoSign: boolean, reason: string, labelsToAdd?: string[]}>} + * Check ARM Analysis workflow results (combines incremental TypeSpec and trivial changes). + * + * @param {WorkflowRun[]} workflowRuns + * @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 {string} owner + * @param {string} repo + * @param {typeof import("@actions/core")} core + * @returns {Promise<{qualifiesForAutoSignoff: boolean, incrementalTypeSpec: boolean, isTrivial: boolean, reason: string}>} */ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) { const wfName = "ARM Auto SignOff - Analyze Code"; @@ -194,7 +333,12 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) core.info( `Found no runs for workflow '${wfName}'. Assuming workflow trigger was skipped, which should be treated equal to "completed false".`, ); - return { shouldAutoSign: false, reason: `No '${wfName}' workflow runs found` }; + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec: false, + isTrivial: false, + reason: `No '${wfName}' workflow runs found`, + }; } // Sorted by "updated_at" descending, so most recent run is at index 0 @@ -202,64 +346,91 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) if (run.status != "completed") { core.info(`Workflow '${wfName}' is still in-progress: status='${run.status}'`); - return { shouldAutoSign: false, reason: `'${wfName}' workflow still in progress` }; + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec: false, + isTrivial: false, + reason: `'${wfName}' workflow still in progress`, + }; } if (run.conclusion != "success") { core.info(`Run for workflow '${wfName}' did not succeed: '${run.conclusion}'`); - return { shouldAutoSign: false, reason: `'${wfName}' workflow failed` }; + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec: false, + isTrivial: false, + reason: `'${wfName}' workflow failed`, + }; } // permissions: { actions: read } - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: PER_PAGE_MAX, - }); + const artifacts = /** @type {Artifact[]} */ ( + await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: PER_PAGE_MAX, + }) + ); + /** @type {string[]} */ const artifactNames = artifacts.map((a) => a.name); core.info(`${wfName} artifactNames: ${JSON.stringify(artifactNames)}`); - // check for new combined artifact - const combinedArtifact = artifactNames.find(name => name.startsWith("arm-auto-signoff-code-results=")); - if (combinedArtifact) { - try { - const resultValue = combinedArtifact.substring("arm-auto-signoff-code-results=".length); - // Parse key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true - // Split by comma, then parse each key-value pair - const keyValuePairs = resultValue.split(','); - - // Extract values directly by key name - const incrementalTypeSpec = keyValuePairs[0]?.endsWith('-true') ?? false; - const isTrivial = keyValuePairs[1]?.endsWith('-true') ?? false; - const qualifiesForAutoSignoff = keyValuePairs[2]?.endsWith('-true') ?? false; - - core.info(`ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}, qualifiesForAutoSignoff=${qualifiesForAutoSignoff}`); - - if (qualifiesForAutoSignoff) { - const labelsToAdd = []; - - if (incrementalTypeSpec) { - labelsToAdd.push("ARMAutoSignedOff-IncrementalTSP"); - } - if (isTrivial) { - labelsToAdd.push("ARMAutoSignedOff-Trivial"); - } - - const reason = labelsToAdd.join(", "); - core.info(`PR qualifies for auto sign-off: ${reason}`); - return { shouldAutoSign: true, reason: reason, labelsToAdd: labelsToAdd }; - } else { - core.info("PR does not qualify for auto sign-off based on ARM analysis"); - return { shouldAutoSign: false, reason: "No qualifying changes detected" }; - } - } catch (error) { - core.warning(`Failed to parse combined ARM analysis results: ${error.message}`); - // Fall back to legacy artifact checking + /** @type {{ incrementalTypeSpec: boolean, isTrivial: boolean, qualifiesForAutoSignoff: boolean } | null} */ + const parsed = artifactNames.map(parseArmAnalysisArtifact).find((r) => r !== null) ?? null; + + if (parsed) { + const { incrementalTypeSpec, isTrivial, qualifiesForAutoSignoff } = parsed; + + core.info( + `ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}, qualifiesForAutoSignoff=${qualifiesForAutoSignoff}`, + ); + + if (!qualifiesForAutoSignoff) { + core.info("PR does not qualify for auto sign-off based on ARM analysis"); + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec, + isTrivial, + reason: "No qualifying changes detected", + }; + } + + if (!incrementalTypeSpec && !isTrivial) { + core.warning( + `Invalid ARM analysis result: qualifies=true but both incrementalTypeSpec and isTrivial are false.`, + ); + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec, + isTrivial, + reason: "Invalid qualifying result", + }; } + + const reason = [ + incrementalTypeSpec ? LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP : null, + isTrivial ? "trivial" : null, + ] + .filter(Boolean) + .join(", "); + + core.info(`PR qualifies for auto sign-off: ${reason}`); + return { + qualifiesForAutoSignoff: true, + incrementalTypeSpec, + isTrivial, + reason, + }; } - + // If we get here, no combined artifact found - treat as not qualifying - return { shouldAutoSign: false, reason: "No combined analysis artifact found" }; + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec: false, + isTrivial: false, + reason: "No combined analysis artifact found", + }; } diff --git a/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js b/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js index f69cef97efb5..33c83f37ec40 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js +++ b/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js @@ -17,10 +17,11 @@ import { CoreLogger } from "../core-logger.js"; debug.enable("simple-git"); /** - * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments + * @param {Pick} args * @returns {Promise} */ -export default async function incrementalTypeSpec({ core }) { +export default async function incrementalTypeSpec(args) { + const { core } = args; const options = { cwd: process.env.GITHUB_WORKSPACE, paths: ["specification"], diff --git a/.github/workflows/src/arm-auto-signoff/pr-changes.js b/.github/workflows/src/arm-auto-signoff/pr-changes.js index 29818ad7a552..5a80ac5bb5c0 100644 --- a/.github/workflows/src/arm-auto-signoff/pr-changes.js +++ b/.github/workflows/src/arm-auto-signoff/pr-changes.js @@ -1,9 +1,9 @@ /** * Represents the types of changes present in a pull request. - * + * * All properties are boolean flags that indicate presence of a change type. * An empty PR would have all properties set to false. - * + * * @typedef {Object} PullRequestChanges * @property {boolean} documentation - True if PR contains documentation (.md) file changes * @property {boolean} examples - True if PR contains example file changes (/examples/*.json) @@ -32,7 +32,7 @@ export function createEmptyPullRequestChanges() { * And does NOT contain: * - Functional spec changes * - Other file types - * + * * @param {PullRequestChanges} changes - The PR changes object * @returns {boolean} - True if PR is trivial */ @@ -41,7 +41,7 @@ export function isTrivialPullRequest(changes) { // Must have at least one of: documentation, examples const hasNoBlockingChanges = !changes.functional && !changes.other; const hasTrivialChanges = changes.documentation || changes.examples; - + return hasNoBlockingChanges && hasTrivialChanges; } @@ -51,10 +51,7 @@ export function isTrivialPullRequest(changes) { * @returns {boolean} - True if only documentation changed */ export function isDocumentationOnly(changes) { - return changes.documentation && - !changes.examples && - !changes.functional && - !changes.other; + return changes.documentation && !changes.examples && !changes.functional && !changes.other; } /** @@ -63,8 +60,5 @@ export function isDocumentationOnly(changes) { * @returns {boolean} - True if only examples changed */ export function isExamplesOnly(changes) { - return !changes.documentation && - changes.examples && - !changes.functional && - !changes.other; + return !changes.documentation && changes.examples && !changes.functional && !changes.other; } diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 7361b855db9b..8de16a08f26a 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -2,20 +2,14 @@ import debug from "debug"; import { simpleGit } from "simple-git"; import { - getChangedFiles, + example, getChangedFilesStatuses, json, - example, markdown, resourceManager, } from "../../../shared/src/changed-files.js"; -import { - createEmptyPullRequestChanges, - isTrivialPullRequest, - isDocumentationOnly, - isExamplesOnly, -} from "./pr-changes.js"; import { CoreLogger } from "../core-logger.js"; +import { createEmptyPullRequestChanges, isTrivialPullRequest } from "./pr-changes.js"; // Enable simple-git debug logging to improve console output debug.enable("simple-git"); @@ -24,45 +18,85 @@ debug.enable("simple-git"); * Non-functional JSON property keys that are safe to change without affecting API behavior */ const NON_FUNCTIONAL_PROPERTIES = new Set([ - 'description', - 'title', - 'summary', - 'x-ms-summary', - 'x-ms-description', - 'externalDocs', - 'x-ms-examples', - 'x-example', - 'example', - 'examples', - 'x-ms-client-name', - 'tags' + "description", + "title", + "summary", + "x-ms-summary", + "x-ms-description", + "externalDocs", + "x-ms-examples", + "x-example", + "example", + "examples", + "x-ms-client-name", + "tags", ]); +/** + * @param {unknown} error + * @returns {string} + */ +function getErrorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * @param {string} text + * @returns {unknown} + */ +function parseJsonUnknown(text) { + return /** @type {unknown} */ (JSON.parse(text)); +} + +/** + * @param {unknown} value + * @returns {string} + */ +function formatForLog(value) { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (value === null) return "null"; + if (value === undefined) return "undefined"; + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +} + /** * Analyzes a PR to determine what types of changes it contains - * @param {{ core: import('@actions/core'), context: import('@actions/github').context }} args + * @param {Pick & Partial>} args * @returns {Promise} JSON string with categorized change types */ -export default async function checkTrivialChanges({ core, context }) { +export default async function checkTrivialChanges(args) { + const { core } = args; // Create result object once at the top const changes = createEmptyPullRequestChanges(); - - // Get the actual PR base branch from GitHub context - // This works for any target branch (main, dev, release/*, etc.) - const baseBranch = context.payload.pull_request?.base?.ref || "main"; - const baseRef = `origin/${baseBranch}`; - - core.info(`Comparing against base branch: ${baseRef}`); - + + // Compare the pull request merge commit (HEAD) against its first parent (HEAD^). + core.info("Comparing against base commit: HEAD^"); + const options = { cwd: process.env.GITHUB_WORKSPACE, logger: new CoreLogger(core), - // Compare against the actual PR base branch - baseCommitish: baseRef, }; - const changedFiles = await getChangedFiles(options); const changedFilesStatuses = await getChangedFilesStatuses(options); + const changedFiles = getFlatChangedFilesFromStatuses(changedFilesStatuses); const git = simpleGit(options.cwd); // Filter to only resource-manager files for ARM auto-signoff @@ -71,7 +105,9 @@ export default async function checkTrivialChanges({ core, context }) { additions: changedFilesStatuses.additions.filter(resourceManager), modifications: changedFilesStatuses.modifications.filter(resourceManager), deletions: changedFilesStatuses.deletions.filter(resourceManager), - renames: changedFilesStatuses.renames.filter(r => resourceManager(r.to) || resourceManager(r.from)) + renames: changedFilesStatuses.renames.filter( + (r) => resourceManager(r.to) || resourceManager(r.from), + ), }; // Early exit if no resource-manager changes @@ -83,11 +119,15 @@ export default async function checkTrivialChanges({ core, context }) { } // Check if PR contains non-resource-manager changes - const hasNonRmChanges = changedFiles.length > changedRmFiles.length; + const hasNonRmChanges = changedFiles.some((file) => !resourceManager(file)); if (hasNonRmChanges) { - const nonRmFiles = changedFiles.filter(file => !resourceManager(file)); - core.info(`PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff`); - core.info(`Non-RM files: ${nonRmFiles.slice(0, 5).join(', ')}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ''}`); + const nonRmFiles = changedFiles.filter((file) => !resourceManager(file)); + core.info( + `PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff`, + ); + core.info( + `Non-RM files: ${nonRmFiles.slice(0, 5).join(", ")}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ""}`, + ); changes.other = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); @@ -95,7 +135,10 @@ export default async function checkTrivialChanges({ core, context }) { } // Check for non-trivial file operations (additions, deletions, renames) - const hasNonTrivialFileOperations = checkForNonTrivialFileOperations(changedRmFilesStatuses, core); + const hasNonTrivialFileOperations = checkForNonTrivialFileOperations( + changedRmFilesStatuses, + core, + ); if (hasNonTrivialFileOperations) { core.info("Non-trivial file operations detected (new spec files, deletions, or renames)"); // These are functional changes by policy @@ -106,11 +149,11 @@ export default async function checkTrivialChanges({ core, context }) { } // Analyze what types of changes are present and update the changes object - await analyzePullRequestChanges(changedRmFiles, git, core, baseRef, changes); + await analyzePullRequestChanges(changedRmFiles, git, core, changes); core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); - + return JSON.stringify(changes); } @@ -122,26 +165,28 @@ export default async function checkTrivialChanges({ core, context }) { */ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { // New spec files (non-example JSON) are non-trivial - const newSpecFiles = changedFilesStatuses.additions.filter(file => - json(file) && !example(file) && !markdown(file) + const newSpecFiles = changedFilesStatuses.additions.filter( + (file) => json(file) && !example(file) && !markdown(file), ); if (newSpecFiles.length > 0) { - core.info(`Non-trivial: New spec files detected: ${newSpecFiles.join(', ')}`); + core.info(`Non-trivial: New spec files detected: ${newSpecFiles.join(", ")}`); return true; } // Deleted spec files (non-example JSON) are non-trivial - const deletedSpecFiles = changedFilesStatuses.deletions.filter(file => - json(file) && !example(file) && !markdown(file) + const deletedSpecFiles = changedFilesStatuses.deletions.filter( + (file) => json(file) && !example(file) && !markdown(file), ); if (deletedSpecFiles.length > 0) { - core.info(`Non-trivial: Deleted spec files detected: ${deletedSpecFiles.join(', ')}`); + core.info(`Non-trivial: Deleted spec files detected: ${deletedSpecFiles.join(", ")}`); return true; } // Any file renames/moves are non-trivial (conservative approach) if (changedFilesStatuses.renames.length > 0) { - core.info(`Non-trivial: File renames detected: ${changedFilesStatuses.renames.map(r => `${r.from} → ${r.to}`).join(', ')}`); + core.info( + `Non-trivial: File renames detected: ${changedFilesStatuses.renames.map((r) => `${r.from} → ${r.to}`).join(", ")}`, + ); return true; } @@ -154,18 +199,19 @@ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { * @param {string[]} changedFiles - Array of changed file paths * @param {import('simple-git').SimpleGit} git - Git instance * @param {typeof import("@actions/core")} core - Core logger - * @param {string} baseRef - The base branch reference (e.g., "origin/main") * @param {import('./pr-changes.js').PullRequestChanges} changes - Changes object to update * @returns {Promise} */ -async function analyzePullRequestChanges(changedFiles, git, core, baseRef, changes) { +async function analyzePullRequestChanges(changedFiles, git, core, changes) { // Categorize files by type const documentationFiles = changedFiles.filter(markdown); const exampleFiles = changedFiles.filter(example); - const specFiles = changedFiles.filter(json).filter(file => !example(file)); - const otherFiles = changedFiles.filter(file => !json(file) && !markdown(file)); + const specFiles = changedFiles.filter(json).filter((file) => !example(file)); + const otherFiles = changedFiles.filter((file) => !json(file) && !markdown(file)); - core.info(`File breakdown: ${documentationFiles.length} docs, ${exampleFiles.length} examples, ${specFiles.length} specs, ${otherFiles.length} other`); + core.info( + `File breakdown: ${documentationFiles.length} docs, ${exampleFiles.length} examples, ${specFiles.length} specs, ${otherFiles.length} other`, + ); // Set flags for file types present if (documentationFiles.length > 0) { @@ -183,14 +229,14 @@ async function analyzePullRequestChanges(changedFiles, git, core, baseRef, chang // Analyze spec files to determine if functional or non-functional if (specFiles.length > 0) { core.info(`Analyzing ${specFiles.length} spec files...`); - + let hasFunctionalChanges = false; for (const file of specFiles) { core.info(`Analyzing file: ${file}`); - + try { - const isNonFunctional = await analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef); + const isNonFunctional = await analyzeSpecFileForNonFunctionalChanges(file, git, core); if (isNonFunctional) { core.info(`File ${file} contains only non-functional changes`); } else { @@ -200,7 +246,7 @@ async function analyzePullRequestChanges(changedFiles, git, core, baseRef, chang break; } } catch (error) { - core.warning(`Failed to analyze ${file}: ${error.message}`); + core.warning(`Failed to analyze ${file}: ${getErrorMessage(error)}`); // On error, treat as functional to be conservative hasFunctionalChanges = true; break; @@ -217,15 +263,14 @@ async function analyzePullRequestChanges(changedFiles, git, core, baseRef, chang * @param {string} file - The file path * @param {import('simple-git').SimpleGit} git - Git instance * @param {typeof import("@actions/core")} core - Core logger - * @param {string} baseRef - The base branch reference (e.g., "origin/main") * @returns {Promise} - True if changes are non-functional only, false if any functional changes detected */ -async function analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef) { +async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { let baseContent, headContent; - + try { - // Get file content from PR base branch - baseContent = await git.show([`${baseRef}:${file}`]); + // Get file content from base commit (merge commit parent) + baseContent = await git.show([`HEAD^:${file}`]); } catch (e) { if (e instanceof Error && e.message.includes("does not exist")) { // New file - should have been caught earlier, but treat as functional to be safe @@ -235,7 +280,7 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef) throw e; } } - + try { headContent = await git.show([`HEAD:${file}`]); } catch (e) { @@ -249,24 +294,49 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef) } let baseJson, headJson; - + try { - baseJson = JSON.parse(baseContent); - headJson = JSON.parse(headContent); + baseJson = parseJsonUnknown(baseContent); + headJson = parseJsonUnknown(headContent); } catch (error) { - core.warning(`Failed to parse JSON for ${file}: ${error.message}`); + core.warning(`Failed to parse JSON for ${file}: ${getErrorMessage(error)}`); return false; } return analyzeJsonDifferences(baseJson, headJson, "", core); } +/** + * Creates a flat, de-duplicated list of changed files from the status results. + * Includes both rename endpoints (`from` and `to`) to ensure path-based filters work as expected. + * @param {{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[]}} statuses + * @returns {string[]} + */ +function getFlatChangedFilesFromStatuses(statuses) { + /** @type {Set} */ + const seen = new Set(); + + /** @param {string} file */ + const add = (file) => { + if (typeof file !== "string" || file.length === 0) return; + seen.add(file); + }; + + for (const file of statuses.additions) add(file); + for (const file of statuses.modifications) add(file); + for (const file of statuses.deletions) add(file); + for (const rename of statuses.renames) { + add(rename.from); + add(rename.to); + } + return Array.from(seen); +} /** * Recursively analyzes differences between two JSON objects - * @param {any} baseObj - Base object - * @param {any} headObj - Head object + * @param {unknown} baseObj - Base object + * @param {unknown} headObj - Head object * @param {string} path - Current path in the object * @param {typeof import("@actions/core")} core - Core logger * @returns {boolean} - True if all differences are non-functional @@ -274,14 +344,14 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core, baseRef) function analyzeJsonDifferences(baseObj, headObj, path, core) { // If types differ, it's a functional change if (typeof baseObj !== typeof headObj) { - core.info(`Type change at ${path || 'root'}: ${typeof baseObj} -> ${typeof headObj}`); + core.info(`Type change at ${path || "root"}: ${typeof baseObj} -> ${typeof headObj}`); return false; } // Handle null values if (baseObj === null || headObj === null) { if (baseObj !== headObj) { - core.info(`Null value change at ${path || 'root'}`); + core.info(`Null value change at ${path || "root"}`); return false; } return true; @@ -292,10 +362,10 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { // For arrays, we need to be careful about order and additions/removals if (baseObj.length !== headObj.length) { // Check if the change is just adding/removing non-functional properties - const arrayPath = path || 'root'; + const arrayPath = path || "root"; // For now, treat any array length change as functional unless we can prove otherwise core.info(`Array length change at ${arrayPath}: ${baseObj.length} -> ${headObj.length}`); - + // Special case: if arrays contain only strings and changes are in non-functional properties like tags if (isNonFunctionalArrayChange(baseObj, headObj, path)) { return true; @@ -313,10 +383,10 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { } // Handle objects - if (typeof baseObj === 'object' && typeof headObj === 'object') { + if (isRecord(baseObj) && isRecord(headObj)) { const baseKeys = new Set(Object.keys(baseObj)); const headKeys = new Set(Object.keys(headObj)); - + // Check for added or removed keys for (const key of baseKeys) { if (!headKeys.has(key)) { @@ -328,7 +398,7 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { core.info(`Property removed at ${fullPath} (non-functional)`); } } - + for (const key of headKeys) { if (!baseKeys.has(key)) { const fullPath = path ? `${path}.${key}` : key; @@ -354,18 +424,22 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { } } } - + return true; } // Handle primitive values if (baseObj !== headObj) { - const currentProperty = path.split('.').pop() || path.split('[')[0]; + const currentProperty = path.split(".").pop() || path.split("[")[0]; if (NON_FUNCTIONAL_PROPERTIES.has(currentProperty)) { - core.info(`Value changed at ${path || 'root'} (non-functional): "${baseObj}" -> "${headObj}"`); + core.info( + `Value changed at ${path || "root"} (non-functional): "${formatForLog(baseObj)}" -> "${formatForLog(headObj)}"`, + ); return true; } - core.info(`Value changed at ${path || 'root'} (functional): "${baseObj}" -> "${headObj}"`); + core.info( + `Value changed at ${path || "root"} (functional): "${formatForLog(baseObj)}" -> "${formatForLog(headObj)}"`, + ); return false; } @@ -380,6 +454,6 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { * @returns {boolean} - True if non-functional */ function isNonFunctionalArrayChange(baseArray, headArray, path) { - const currentProperty = path.split('.').pop() || path; + const currentProperty = path.split(".").pop() || path; return NON_FUNCTIONAL_PROPERTIES.has(currentProperty); } diff --git a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js index 0f7784ad72b9..66f4f3dbf9ac 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js @@ -1,10 +1,77 @@ import { describe, expect, it } from "vitest"; import { CommitStatusState } from "../../../shared/src/github.js"; import { getLabelActionImpl } from "../../src/arm-auto-signoff/arm-auto-signoff-status.js"; +import { LabelAction } from "../../src/label.js"; import { createMockCore, createMockGithub as createMockGithubBase } from "../mocks.js"; const core = createMockCore(); +const managedLabels = Object.freeze({ + armSignedOff: "ARMSignedOff", + autoSignedOffIncrementalTsp: "ARMAutoSignedOff-IncrementalTSP", + autoSignedOffTrivialTest: "ARMAutoSignedOff-Trivial-Test", +}); + +function createNoneLabelActions() { + return { + [managedLabels.armSignedOff]: LabelAction.None, + [managedLabels.autoSignedOffIncrementalTsp]: LabelAction.None, + [managedLabels.autoSignedOffTrivialTest]: LabelAction.None, + }; +} + +/** + * @param {string} headSha + * @param {number} issueNumber + */ +function createNoneResult(headSha, issueNumber) { + return { + headSha, + issueNumber, + labelActions: createNoneLabelActions(), + }; +} + +/** + * @param {string} headSha + * @param {number} issueNumber + */ +function createRemoveManagedLabelsResult(headSha, issueNumber) { + return { + headSha, + issueNumber, + labelActions: { + ...createNoneLabelActions(), + [managedLabels.armSignedOff]: LabelAction.Remove, + [managedLabels.autoSignedOffIncrementalTsp]: LabelAction.Remove, + [managedLabels.autoSignedOffTrivialTest]: LabelAction.Remove, + }, + }; +} + +/** + * @param {{ + * headSha: string, + * issueNumber: number, + * incrementalTypeSpec: boolean, + * isTrivial: boolean, + * }} params + */ +function createSuccessResult({ headSha, issueNumber, incrementalTypeSpec, isTrivial }) { + return { + headSha, + issueNumber, + labelActions: { + ...createNoneLabelActions(), + [managedLabels.armSignedOff]: incrementalTypeSpec ? LabelAction.Add : LabelAction.None, + [managedLabels.autoSignedOffIncrementalTsp]: incrementalTypeSpec + ? LabelAction.Add + : LabelAction.Remove, + [managedLabels.autoSignedOffTrivialTest]: isTrivial ? LabelAction.Add : LabelAction.Remove, + }, + }; +} + /** * @param {Object} param0 * @param {boolean} param0.incrementalTypeSpec @@ -32,10 +99,15 @@ function createMockGithub({ incrementalTypeSpec }) { }); // Return artifact in the format: arm-auto-signoff-code-results=incrementalTypeSpec-VALUE,isTrivial-VALUE,qualifies-VALUE + const incremental = incrementalTypeSpec ? "true" : "false"; const qualifies = incrementalTypeSpec ? "true" : "false"; github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ data: { - artifacts: [{ name: `arm-auto-signoff-code-results=incrementalTypeSpec-${qualifies},isTrivial-false,qualifies-${qualifies}` }], + artifacts: [ + { + name: `arm-auto-signoff-code-results=incrementalTypeSpec-${incremental},isTrivial-false,qualifies-${qualifies}`, + }, + ], }, }); @@ -49,7 +121,7 @@ describe("getLabelActionImpl", () => { ).rejects.toThrow(); }); - it("throws if no artifact from incremental typespec", async () => { + it("no-ops if no artifact from incremental typespec", async () => { const github = createMockGithub({ incrementalTypeSpec: false }); github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ data: { artifacts: [] }, @@ -64,7 +136,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).rejects.toThrow(); + ).resolves.toEqual(createNoneResult("abc123", 123)); }); it("no-ops if no current label ARMAutoSignedOff", async () => { @@ -82,7 +154,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); + ).resolves.toEqual(createNoneResult("abc123", 123)); }); it("removes label if not incremental typespec", async () => { @@ -100,7 +172,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }); it("no-ops if incremental typespec in progress", async () => { @@ -127,7 +199,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); + ).resolves.toEqual(createNoneResult("abc123", 123)); }); it("removes label if no runs of incremental typespec", async () => { @@ -150,7 +222,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }); it("uses latest run of incremental typespec", async () => { @@ -188,7 +260,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }); it.each([ @@ -211,7 +283,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }); it.each(["Swagger Avocado", "Swagger LintDiff"])( @@ -240,16 +312,33 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: [] }); + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }, ); it.each([ - [CommitStatusState.ERROR, ["ARMReview", "ARMAutoSignedOff-IncrementalTSP"], []], - [CommitStatusState.FAILURE, ["ARMReview", "ARMAutoSignedOff-IncrementalTSP"], []], - [CommitStatusState.PENDING, ["ARMReview"], undefined], - [CommitStatusState.SUCCESS, ["ARMReview"], ["ARMAutoSignedOff-IncrementalTSP"]], - ])("uses latest status if multiple (%o)", async (state, labels, expectedAutoSignOffLabels) => { + [ + CommitStatusState.ERROR, + ["ARMReview", "ARMAutoSignedOff-IncrementalTSP"], + createRemoveManagedLabelsResult("abc123", 123), + ], + [ + CommitStatusState.FAILURE, + ["ARMReview", "ARMAutoSignedOff-IncrementalTSP"], + createRemoveManagedLabelsResult("abc123", 123), + ], + [CommitStatusState.PENDING, ["ARMReview"], createNoneResult("abc123", 123)], + [ + CommitStatusState.SUCCESS, + ["ARMReview"], + createSuccessResult({ + headSha: "abc123", + issueNumber: 123, + incrementalTypeSpec: true, + isTrivial: false, + }), + ], + ])("uses latest status if multiple (%o)", async (state, labels, expectedResult) => { const github = createMockGithub({ incrementalTypeSpec: true }); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ @@ -287,7 +376,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: expectedAutoSignOffLabels }); + ).resolves.toEqual(expectedResult); }); it("no-ops if check not found or not completed", async () => { @@ -309,7 +398,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); + ).resolves.toEqual(createNoneResult("abc123", 123)); github.rest.repos.listCommitStatusesForRef.mockResolvedValue({ data: [{ context: "Swagger LintDiff", state: CommitStatusState.PENDING }], @@ -323,7 +412,7 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: undefined }); + ).resolves.toEqual(createNoneResult("abc123", 123)); }); it("adds label if incremental tsp, labels match, and check succeeded", async () => { @@ -354,6 +443,13 @@ describe("getLabelActionImpl", () => { github: github, core: core, }), - ).resolves.toEqual({ headSha: "abc123", issueNumber: 123, autoSignOffLabels: ["ARMAutoSignedOff-IncrementalTSP"] }); + ).resolves.toEqual( + createSuccessResult({ + headSha: "abc123", + issueNumber: 123, + incrementalTypeSpec: true, + isTrivial: false, + }), + ); }); }); diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index d125fc42b627..2b593c18b811 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -9,28 +9,74 @@ vi.mock("simple-git", () => ({ import * as simpleGit from "simple-git"; import * as changedFiles from "../../../shared/src/changed-files.js"; import checkTrivialChanges from "../../src/arm-auto-signoff/trivial-changes-check.js"; -import { createMockCore, createMockContext } from "../mocks.js"; +import { createMockContext, createMockCore } from "../mocks.js"; const core = createMockCore(); const context = createMockContext(); +/** @typedef {import("../../src/arm-auto-signoff/pr-changes.js").PullRequestChanges} PullRequestChanges */ + +/** + * @param {unknown} value + * @returns {value is PullRequestChanges} + */ +function isPullRequestChanges(value) { + if (typeof value !== "object" || value === null) { + return false; + } + /** @type {Record} */ + const obj = /** @type {Record} */ (value); + return ( + typeof obj.documentation === "boolean" && + typeof obj.examples === "boolean" && + typeof obj.functional === "boolean" && + typeof obj.other === "boolean" + ); +} + +/** + * @param {string} jsonText + * @returns {PullRequestChanges} + */ +function parsePullRequestChanges(jsonText) { + const parsed = /** @type {unknown} */ (JSON.parse(jsonText)); + if (!isPullRequestChanges(parsed)) { + throw new Error("Unexpected PullRequestChanges JSON shape"); + } + return parsed; +} + +/** + * The tests mock `simple-git` to return a minimal object with only `show`. + * Return that mock with a precise type so ESLint doesn't treat it as an unbound method. + * @returns {{ show: import("vitest").Mock<(args: string[]) => Promise> }} + */ +function getMockGit() { + return /** @type {{ show: import("vitest").Mock<(args: string[]) => Promise> }} */ ( + /** @type {unknown} */ (simpleGit.simpleGit()) + ); +} + describe("checkTrivialChanges", () => { afterEach(() => { vi.clearAllMocks(); }); it("throws error when core and context are not provided", async () => { - await expect(checkTrivialChanges({})).rejects.toThrow(); + await expect(checkTrivialChanges(/** @type {any} */ ({}))).rejects.toThrow(); }); it("returns empty change flags when no files are changed", async () => { - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([]); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: [], deletions: [], renames: [] + additions: [], + modifications: [], + deletions: [], + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -39,22 +85,25 @@ describe("checkTrivialChanges", () => { other: false, }); }); - + it("marks PR as 'other' when non-resource-manager files are changed", async () => { const mixedFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", "specification/someservice/data-plane/Service/stable/2021-01-01/service.json", // data-plane (non-RM) "README.md", // root level file (non-RM) - ".github/workflows/ci.yml" // workflow file (non-RM) + ".github/workflows/ci.yml", // workflow file (non-RM) ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: mixedFiles, deletions: [], renames: [] + additions: [], + modifications: mixedFiles, + deletions: [], + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -68,16 +117,19 @@ describe("checkTrivialChanges", () => { const nonRmFiles = [ "specification/someservice/data-plane/Service/stable/2021-01-01/service.json", "package.json", - "tsconfig.json" + "tsconfig.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(nonRmFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: nonRmFiles, deletions: [], renames: [] + additions: [], + modifications: nonRmFiles, + deletions: [], + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -89,19 +141,19 @@ describe("checkTrivialChanges", () => { it("detects functional changes when new spec files are added", async () => { const filesWithAdditions = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newservice.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newservice.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(filesWithAdditions); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ additions: filesWithAdditions, // New spec file modifications: [], deletions: [], - renames: [] + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -113,19 +165,19 @@ describe("checkTrivialChanges", () => { it("detects functional changes when spec files are deleted", async () => { const deletedFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/oldservice.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/oldservice.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(deletedFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ additions: [], modifications: [], deletions: deletedFiles, // Deleted spec file - renames: [] + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -136,21 +188,21 @@ describe("checkTrivialChanges", () => { }); it("detects functional changes when spec files are renamed", async () => { - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newname.json" - ]); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ additions: [], modifications: [], deletions: [], - renames: [{ - from: "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/oldname.json", - to: "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newname.json" - }] + renames: [ + { + from: "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/oldname.json", + to: "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/newname.json", + }, + ], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -163,16 +215,19 @@ describe("checkTrivialChanges", () => { it("detects documentation-only changes when only .md files are modified", async () => { const mdFiles = [ "specification/someservice/resource-manager/readme.md", - "specification/someservice/resource-manager/Microsoft.Service/preview/2021-01-01/README.md" + "specification/someservice/resource-manager/Microsoft.Service/preview/2021-01-01/README.md", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mdFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: mdFiles, deletions: [], renames: [] + additions: [], + modifications: mdFiles, + deletions: [], + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: true, @@ -185,16 +240,19 @@ describe("checkTrivialChanges", () => { it("detects examples-only changes when only example JSON files are modified", async () => { const exampleFiles = [ "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(exampleFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: exampleFiles, deletions: [], renames: [] + additions: [], + modifications: exampleFiles, + deletions: [], + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -206,43 +264,44 @@ describe("checkTrivialChanges", () => { it("identifies non-functional changes when only description and summary properties are modified", async () => { const jsonFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", ]; const oldJson = JSON.stringify({ info: { title: "Service API", version: "1.0" }, - paths: { "/test": { get: { summary: "Test endpoint" } } } + paths: { "/test": { get: { summary: "Test endpoint" } } }, }); const newJson = JSON.stringify({ - info: { - title: "Service API", + info: { + title: "Service API", version: "1.0", - description: "New description" // Non-functional change + description: "New description", // Non-functional change }, - paths: { "/test": { get: { summary: "Test endpoint updated" } } } // Non-functional change + paths: { "/test": { get: { summary: "Test endpoint updated" } } }, // Non-functional change }); - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: jsonFiles, deletions: [], renames: [] - }); - - const showSpy = vi.mocked(simpleGit.simpleGit().show) - .mockImplementation((args) => { - if (Array.isArray(args)) { - const ref = args[0]; - if (ref.includes("origin/main:")) { - return Promise.resolve(oldJson); - } else if (ref.startsWith("HEAD:")) { - return Promise.resolve(newJson); - } - } - return Promise.reject(new Error("does not exist")); - }); + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + const showSpy = getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -256,42 +315,43 @@ describe("checkTrivialChanges", () => { it("detects functional changes when new API endpoints are added", async () => { const jsonFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", ]; const oldJson = JSON.stringify({ info: { title: "Service API", version: "1.0" }, - paths: { "/test": { get: { summary: "Test endpoint" } } } + paths: { "/test": { get: { summary: "Test endpoint" } } }, }); const newJson = JSON.stringify({ info: { title: "Service API", version: "1.0" }, - paths: { + paths: { "/test": { get: { summary: "Test endpoint" } }, - "/new": { post: { summary: "New endpoint" } } // Functional change - } + "/new": { post: { summary: "New endpoint" } }, // Functional change + }, }); - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: jsonFiles, deletions: [], renames: [] - }); - - vi.mocked(simpleGit.simpleGit().show) - .mockImplementation((args) => { - if (Array.isArray(args)) { - const ref = args[0]; - if (ref.includes("origin/main:")) { - return Promise.resolve(oldJson); - } else if (ref.startsWith("HEAD:")) { - return Promise.resolve(newJson); - } - } - return Promise.reject(new Error("does not exist")); - }); + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -305,42 +365,43 @@ describe("checkTrivialChanges", () => { const mixedFiles = [ "specification/someservice/resource-manager/Microsoft.Service/README.md", // Documentation (trivial) "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", // Examples (trivial) - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" // Functional change (non-trivial) + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", // Functional change (non-trivial) ]; const oldJson = JSON.stringify({ - paths: { "/test": { get: { summary: "Test endpoint" } } } + paths: { "/test": { get: { summary: "Test endpoint" } } }, }); const newJson = JSON.stringify({ - paths: { + paths: { "/test": { get: { summary: "Test endpoint" } }, - "/new": { post: { summary: "New endpoint" } } // Functional change - } + "/new": { post: { summary: "New endpoint" } }, // Functional change + }, }); - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: mixedFiles, deletions: [], renames: [] - }); - - vi.mocked(simpleGit.simpleGit().show) - .mockImplementation((args) => { - if (Array.isArray(args)) { - const ref = args[0]; - if (ref.includes("service.json")) { - if (ref.includes("origin/main:")) { - return Promise.resolve(oldJson); - } else if (ref.startsWith("HEAD:")) { - return Promise.resolve(newJson); - } - } + additions: [], + modifications: mixedFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.includes("service.json")) { + if (ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); } - return Promise.reject(new Error("does not exist")); - }); + } + return Promise.reject(new Error("does not exist")); + }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); // Should detect functional changes, so overall not trivial expect(parsed).toEqual({ @@ -353,29 +414,30 @@ describe("checkTrivialChanges", () => { it("treats JSON parsing errors as functional changes to be conservative", async () => { const jsonFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: jsonFiles, deletions: [], renames: [] - }); - - vi.mocked(simpleGit.simpleGit().show) - .mockImplementation((args) => { - if (Array.isArray(args)) { - const ref = args[0]; - if (ref.includes("origin/main:")) { - return Promise.resolve("{ invalid json"); - } else if (ref.startsWith("HEAD:")) { - return Promise.resolve("{ also invalid }"); - } - } - return Promise.reject(new Error("does not exist")); - }); + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve("{ invalid json"); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve("{ also invalid }"); + } + return Promise.reject(new Error("does not exist")); + }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); // Should treat JSON parsing errors as non-trivial changes expect(parsed).toEqual({ @@ -388,21 +450,23 @@ describe("checkTrivialChanges", () => { it("treats git operation errors as functional changes to be conservative", async () => { const jsonFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: jsonFiles, deletions: [], renames: [] + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, }); - - vi.mocked(simpleGit.simpleGit().show) - .mockRejectedValue(new Error("Git operation failed")); + + getMockGit().show.mockRejectedValue(new Error("Git operation failed")); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); - // Should treat git errors as non-trivial changes + // Should treat git errors as non-trivial changes expect(parsed).toEqual({ documentation: false, examples: false, @@ -413,59 +477,60 @@ describe("checkTrivialChanges", () => { it("correctly identifies non-functional changes in nested object properties", async () => { const jsonFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", ]; const oldJson = JSON.stringify({ info: { title: "Service API", version: "1.0" }, - paths: { - "/test": { - get: { + paths: { + "/test": { + get: { summary: "Test endpoint", description: "Gets test data", - operationId: "Test_Get" - } - } - } + operationId: "Test_Get", + }, + }, + }, }); const newJson = JSON.stringify({ - info: { - title: "Service API", + info: { + title: "Service API", version: "1.0", - description: "API description" // Non-functional change + description: "API description", // Non-functional change }, - paths: { - "/test": { - get: { + paths: { + "/test": { + get: { summary: "Updated test endpoint", // Non-functional change description: "Gets test data", - operationId: "Test_Get" - } - } - } + operationId: "Test_Get", + }, + }, + }, }); - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: jsonFiles, deletions: [], renames: [] - }); - - vi.mocked(simpleGit.simpleGit().show) - .mockImplementation((args) => { - if (Array.isArray(args)) { - const ref = args[0]; - if (ref.includes("origin/main:")) { - return Promise.resolve(oldJson); - } else if (ref.startsWith("HEAD:")) { - return Promise.resolve(newJson); - } - } - return Promise.reject(new Error("does not exist")); - }); + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -477,56 +542,55 @@ describe("checkTrivialChanges", () => { it("detects functional changes when parameters are added to API operations", async () => { const jsonFiles = [ - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", ]; const oldJson = JSON.stringify({ info: { title: "Service API", version: "1.0" }, - paths: { - "/test": { - get: { - parameters: [ - { name: "id", in: "path", required: true, type: "string" } - ] - } - } - } + paths: { + "/test": { + get: { + parameters: [{ name: "id", in: "path", required: true, type: "string" }], + }, + }, + }, }); const newJson = JSON.stringify({ info: { title: "Service API", version: "1.0" }, - paths: { - "/test": { - get: { + paths: { + "/test": { + get: { parameters: [ { name: "id", in: "path", required: true, type: "string" }, - { name: "filter", in: "query", required: false, type: "string" } // Functional change - ] - } - } - } + { name: "filter", in: "query", required: false, type: "string" }, // Functional change + ], + }, + }, + }, }); - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(jsonFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: jsonFiles, deletions: [], renames: [] - }); - - vi.mocked(simpleGit.simpleGit().show) - .mockImplementation((args) => { - if (Array.isArray(args)) { - const ref = args[0]; - if (ref.includes("origin/main:")) { - return Promise.resolve(oldJson); - } else if (ref.startsWith("HEAD:")) { - return Promise.resolve(newJson); - } - } - return Promise.reject(new Error("does not exist")); - }); + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: false, @@ -540,16 +604,19 @@ describe("checkTrivialChanges", () => { const mixedTrivialFiles = [ "specification/someservice/resource-manager/Microsoft.Service/README.md", "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Get.json", - "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json" + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/examples/Create.json", ]; - vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue(mixedTrivialFiles); vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ - additions: [], modifications: mixedTrivialFiles, deletions: [], renames: [] + additions: [], + modifications: mixedTrivialFiles, + deletions: [], + renames: [], + total: 0, }); const result = await checkTrivialChanges({ core, context }); - const parsed = JSON.parse(result); + const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ documentation: true, @@ -558,4 +625,4 @@ describe("checkTrivialChanges", () => { other: false, }); }); -}); \ No newline at end of file +}); From 52c85dfde553207688ecfa0400a245c0eb7a13cc Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Tue, 16 Dec 2025 18:20:22 +0000 Subject: [PATCH 21/36] formatting --- .../src/arm-auto-signoff/trivial-changes-auto-signoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md b/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md index 88e651426367..9c8c41a4dbfa 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md @@ -67,7 +67,7 @@ All other requirements must still be met (proper labels, passing status checks, When trivial changes are detected and auto sign-off is applied, the following labels may be added: - `DocumentationOnlyChanges` - For documentation-only PRs -- `ExamplesOnlyChanges` - For examples-only PRs +- `ExamplesOnlyChanges` - For examples-only PRs - `NonFunctionalChanges` - For non-functional JSON changes ## Files Changed From 9c77e7362180e8020a9a3de491df8cd5ea795e5f Mon Sep 17 00:00:00 2001 From: Mike Harder Date: Tue, 16 Dec 2025 15:00:26 -0800 Subject: [PATCH 22/36] yaml: "if" before "name" --- .github/workflows/arm-auto-signoff-status.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 4733ccd50acf..fd0f8a4e1536 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -95,22 +95,22 @@ jobs: # Add/remove specific auto sign-off labels based on analysis results. # The action is explicit: 'add' | 'remove' | 'none'. - - name: Upload artifact for ARMSignedOff label - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMSignedOff'] != 'none' + - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMSignedOff'] != 'none' + name: Upload artifact for ARMSignedOff label uses: ./.github/actions/add-label-artifact with: name: "ARMSignedOff" value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMSignedOff'] == 'add' }}" - - name: Upload artifact for ARMAutoSignedOff-IncrementalTSP label - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] != 'none' + - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] != 'none' + name: Upload artifact for ARMAutoSignedOff-IncrementalTSP label uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-IncrementalTSP" value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] == 'add' }}" - - name: Upload artifact for ARMAutoSignedOff-Trivial-Test label - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] != 'none' + - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] != 'none' + name: Upload artifact for ARMAutoSignedOff-Trivial-Test label uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-Trivial-Test" From 1146025c813e7571f7b1507acd6c43e5ce9155a4 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Wed, 17 Dec 2025 10:11:55 -0600 Subject: [PATCH 23/36] Refactor dryrun mode and just add trivial-test label, Update PRChanges output to include RM specific fields vs other --- .../workflows/arm-auto-signoff-status.yaml | 5 - .../arm-auto-signoff/arm-auto-signoff-code.js | 12 +- .../arm-auto-signoff-status.js | 53 ++----- .../arm-incremental-typespec.js | 5 +- .../src/arm-auto-signoff/pr-changes.js | 37 +++-- .../trivial-changes-auto-signoff.md | 115 -------------- .../arm-auto-signoff/trivial-changes-check.js | 33 ++-- .../trivial-changes-check.test.js | 147 +++++++++++------- 8 files changed, 162 insertions(+), 245 deletions(-) delete mode 100644 .github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 4733ccd50acf..931fd0006def 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -36,11 +36,6 @@ jobs: arm-auto-signoff-status: name: ARM Auto SignOff - Set Status - env: - # When enabled, trivial auto-signoff is routed to the "-Test" label and - # ARMSignedOff is only auto-managed for IncrementalTSP. - ARM_AUTO_SIGNOFF_TRIVIAL_DRYRUN: "true" - # workflow_run - already filtered by triggers above # pull_request_target:labeled - filter to only the input and output labels if: | diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 5a74c7ea4e1a..d6745246b362 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -17,9 +17,10 @@ function isPullRequestChanges(value) { const obj = /** @type {Record} */ (value); return ( - typeof obj.documentation === "boolean" && - typeof obj.examples === "boolean" && - typeof obj.functional === "boolean" && + typeof obj.rmDocumentation === "boolean" && + typeof obj.rmExamples === "boolean" && + typeof obj.rmFunctional === "boolean" && + typeof obj.rmOther === "boolean" && typeof obj.other === "boolean" ); } @@ -42,14 +43,14 @@ export default async function armAutoSignoffCode(args) { // Run incremental TypeSpec check core.startGroup("Checking for incremental TypeSpec changes"); - const incrementalTypeSpecResult = await incrementalTypeSpec({ core }); + const incrementalTypeSpecResult = await incrementalTypeSpec(args); core.info(`Incremental TypeSpec result: ${incrementalTypeSpecResult}`); core.endGroup(); // Run trivial changes check core.startGroup("Checking for trivial changes"); const trivialChangesResultString = await checkTrivialChanges({ core, context }); - const parsedTrivialChanges = /** @type {unknown} */ (JSON.parse(trivialChangesResultString)); + /** @type {unknown} */ const parsedTrivialChanges = (JSON.parse(trivialChangesResultString)); if (!isPullRequestChanges(parsedTrivialChanges)) { throw new Error("Unexpected trivial changes result shape"); } @@ -69,7 +70,6 @@ export default async function armAutoSignoffCode(args) { }; core.info(`Combined result: ${JSON.stringify(combined, null, 2)}`); - core.info(`Qualifies for auto sign-off: ${combined.qualifiesForAutoSignoff}`); // Return key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true return `incrementalTypeSpec-${incrementalTypeSpecResult},isTrivial-${isTrivial},qualifies-${qualifiesForAutoSignoff}`; diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 013cb99bda99..5d3a58e30cf5 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -10,8 +10,6 @@ const LABEL_ARM_AUTO_SIGNED_OFF = "ARMAutoSignedOff"; const LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP = "ARMAutoSignedOff-IncrementalTSP"; const LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST = "ARMAutoSignedOff-Trivial-Test"; -const ENV_TRIVIAL_DRYRUN = "ARM_AUTO_SIGNOFF_TRIVIAL_DRYRUN"; - /** * The workflow contract is intentionally a fixed set of keys. * @typedef {{ @@ -54,18 +52,6 @@ function createNoneLabelActions() { /** @typedef {{ name: string }} Artifact */ -/** - * @param {typeof import("@actions/core")} core - * @returns {boolean} - */ -function isTrivialDryRunEnabled(core) { - const raw = process.env[ENV_TRIVIAL_DRYRUN]; - const value = (raw ?? "").trim().toLowerCase(); - const enabled = value === "true"; - core.info(`${ENV_TRIVIAL_DRYRUN}=${raw ?? ""} (enabled=${enabled})`); - return enabled; -} - /** * @param {string} artifactName * @returns {{ incrementalTypeSpec: boolean, isTrivial: boolean, qualifiesForAutoSignoff: boolean } | null} @@ -133,8 +119,6 @@ export default async function getLabelAction({ github, context, core }) { * @returns {Promise<{headSha: string, issueNumber: number, labelActions: ManagedLabelActions}>} */ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, github, core }) { - const trivialDryRun = isTrivialDryRunEnabled(core); - // TODO: Try to extract labels from context (when available) to avoid unnecessary API call // permissions: { issues: read, pull-requests: read } const labels = /** @type {IssueLabel[]} */ ( @@ -193,7 +177,6 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return noneResult; } - // Only remove ARMSignedOff if it was auto-managed. return { ...noneResult, labelActions: { @@ -277,14 +260,16 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, core.info("All requirements met for auto-signoff"); const autoIncremental = armAnalysisResult.incrementalTypeSpec; - const autoTrivialTest = trivialDryRun && armAnalysisResult.isTrivial; - - // Trivial auto-signoff is supported ONLY in dryrun/test mode. - // Outside of dryrun, trivial results are ignored. - if (!autoIncremental && !autoTrivialTest) { - return removeAutoSignedOffLabelsIfPresent(); - } - + const autoTrivialTest = armAnalysisResult.isTrivial; + + // Add ARMAutoSignOff label only when the PR is identified as an incremental typespec + // As the trivial changes sign-off is being released in test mode + const autoSignOffAction = autoIncremental + ? LabelAction.Add + : hasAutoSignedOffLabels + ? LabelAction.Remove + : LabelAction.None; + const autoIncrementalAction = autoIncremental ? LabelAction.Add : LabelAction.Remove; const testTrivialAction = autoTrivialTest ? LabelAction.Add : LabelAction.Remove; // Keep labels in sync with current analysis results. @@ -293,14 +278,8 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, ...baseResult, labelActions: { ...labelActions, - [LABEL_ARM_SIGNED_OFF]: autoIncremental - ? LabelAction.Add - : hasAutoSignedOffLabels - ? LabelAction.Remove - : LabelAction.None, - [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: autoIncremental - ? LabelAction.Add - : LabelAction.Remove, + [LABEL_ARM_SIGNED_OFF]: autoSignOffAction, + [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: autoIncrementalAction, [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: testTrivialAction, }, }; @@ -412,10 +391,10 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) const reason = [ incrementalTypeSpec ? LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP : null, - isTrivial ? "trivial" : null, + isTrivial ? LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST : null, ] - .filter(Boolean) - .join(", "); + .filter(Boolean) + .join(", "); core.info(`PR qualifies for auto sign-off: ${reason}`); return { @@ -433,4 +412,4 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) isTrivial: false, reason: "No combined analysis artifact found", }; -} +} \ No newline at end of file diff --git a/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js b/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js index 33c83f37ec40..f69cef97efb5 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js +++ b/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js @@ -17,11 +17,10 @@ import { CoreLogger } from "../core-logger.js"; debug.enable("simple-git"); /** - * @param {Pick} args + * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments * @returns {Promise} */ -export default async function incrementalTypeSpec(args) { - const { core } = args; +export default async function incrementalTypeSpec({ core }) { const options = { cwd: process.env.GITHUB_WORKSPACE, paths: ["specification"], diff --git a/.github/workflows/src/arm-auto-signoff/pr-changes.js b/.github/workflows/src/arm-auto-signoff/pr-changes.js index 5a80ac5bb5c0..ab4b5f661f98 100644 --- a/.github/workflows/src/arm-auto-signoff/pr-changes.js +++ b/.github/workflows/src/arm-auto-signoff/pr-changes.js @@ -5,10 +5,11 @@ * An empty PR would have all properties set to false. * * @typedef {Object} PullRequestChanges - * @property {boolean} documentation - True if PR contains documentation (.md) file changes - * @property {boolean} examples - True if PR contains example file changes (/examples/*.json) - * @property {boolean} functional - True if PR contains functional spec changes (API-impacting) - * @property {boolean} other - True if PR contains other file types (config, scripts, etc.) + * @property {boolean} rmDocumentation - True if PR contains resource-manager scoped documentation (.md) changes + * @property {boolean} rmExamples - True if PR contains resource-manager scoped example changes (/examples/*.json) + * @property {boolean} rmFunctional - True if PR contains resource-manager scoped functional spec changes (API-impacting) + * @property {boolean} rmOther - True if PR contains other resource-manager scoped changes (non-trivial) + * @property {boolean} other - True if PR contains changes outside resource-manager (blocks ARM auto-signoff) */ /** @@ -17,9 +18,10 @@ */ export function createEmptyPullRequestChanges() { return { - documentation: false, - examples: false, - functional: false, + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, other: false, }; } @@ -39,8 +41,9 @@ export function createEmptyPullRequestChanges() { export function isTrivialPullRequest(changes) { // Trivial if no functional changes and no other files // Must have at least one of: documentation, examples - const hasNoBlockingChanges = !changes.functional && !changes.other; - const hasTrivialChanges = changes.documentation || changes.examples; + const hasNoBlockingChanges = + !changes.rmFunctional && !changes.rmOther && !changes.other; + const hasTrivialChanges = changes.rmDocumentation || changes.rmExamples; return hasNoBlockingChanges && hasTrivialChanges; } @@ -51,7 +54,13 @@ export function isTrivialPullRequest(changes) { * @returns {boolean} - True if only documentation changed */ export function isDocumentationOnly(changes) { - return changes.documentation && !changes.examples && !changes.functional && !changes.other; + return ( + changes.rmDocumentation && + !changes.rmExamples && + !changes.rmFunctional && + !changes.rmOther && + !changes.other + ); } /** @@ -60,5 +69,11 @@ export function isDocumentationOnly(changes) { * @returns {boolean} - True if only examples changed */ export function isExamplesOnly(changes) { - return !changes.documentation && changes.examples && !changes.functional && !changes.other; + return ( + !changes.rmDocumentation && + changes.rmExamples && + !changes.rmFunctional && + !changes.rmOther && + !changes.other + ); } diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md b/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md deleted file mode 100644 index 88e651426367..000000000000 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-auto-signoff.md +++ /dev/null @@ -1,115 +0,0 @@ -# Integrated Trivial Changes Auto Signoff - -This document describes the integrated trivial changes detection functionality within the ARM Incremental TypeSpec -workflow that extends auto signoff capabilities. - -## Overview - -The ARM Incremental TypeSpec workflow now includes built-in detection for three types of trivial changes that can -qualify for auto sign-off: - -1. **Documentation Only Changes** - PRs that only modify `.md` files -2. **Examples Only Changes** - PRs that only modify files in `/examples/` folders -3. **Non-functional Changes** - PRs that only modify non-functional properties in JSON files - -## Workflow Details - -### ARM Incremental TypeSpec Workflow (`arm-incremental-typespec.yaml`) - -This workflow has been enhanced to run both the original ARM incremental TypeSpec analysis and the new trivial -changes detection. It produces: - -1. **Legacy artifact** - `incremental-typespec=true/false` (for backward compatibility) -2. **Combined artifact** - `arm-analysis-results` containing a JSON structure: - - ```json - { - "incrementalTypeSpec": true/false, - "trivialChanges": { - "documentationOnlyChanges": true/false, - "examplesOnlyChanges": true/false, - "nonFunctionalChanges": true/false, - "anyTrivialChanges": true/false - }, - "qualifiesForAutoSignOff": true/false - } - ``` - -### Non-functional Properties - -The following JSON properties are considered non-functional (safe to change without affecting API behavior): - -- `description`, `title`, `summary` -- `x-ms-summary`, `x-ms-description` -- `externalDocs` -- `x-ms-examples`, `x-example`, `example`, `examples` -- `x-ms-client-name`, `x-ms-parameter-name` -- `x-ms-enum`, `x-ms-discriminator-value` -- `x-ms-client-flatten`, `x-ms-azure-resource` -- `x-ms-mutability`, `x-ms-secret` -- `x-ms-parameter-location`, `x-ms-skip-url-encoding` -- `x-ms-long-running-operation`, `x-ms-long-running-operation-options` -- `x-ms-pageable`, `x-ms-odata` -- `tags`, `x-ms-api-annotation` - -### ARM Auto Sign-off Integration - -The ARM auto sign-off workflow consumes the combined artifact from the ARM Incremental TypeSpec workflow. -Auto sign-off is applied if: - -1. The PR contains incremental TypeSpec changes, OR -2. The PR contains any qualifying trivial changes - -All other requirements must still be met (proper labels, passing status checks, etc.). - -### Labels - -When trivial changes are detected and auto sign-off is applied, the following labels may be added: - -- `DocumentationOnlyChanges` - For documentation-only PRs -- `ExamplesOnlyChanges` - For examples-only PRs -- `NonFunctionalChanges` - For non-functional JSON changes - -## Files Changed - -### Modified Files - -- `.github/workflows/arm-incremental-typespec.yaml` - Enhanced with trivial changes detection -- `.github/workflows/src/arm-auto-signoff.js` - Updated to handle combined analysis results -- `.github/workflows/arm-auto-signoff.yaml` - Updated label handling for new artifact format - -### New Files - -- `.github/workflows/src/trivial-changes-check.js` - Trivial changes detection logic -- `.github/workflows/test/trivial-changes-check.test.js` - Basic tests - -## Configuration - -No additional configuration is required. The workflow automatically runs on all PRs and detects trivial changes -based on the predefined criteria. - -## Testing - -To test the functionality: - -1. Create a PR with only documentation changes (`.md` files) -2. Create a PR with only example changes (files in `/examples/` folders) -3. Create a PR with only non-functional JSON property changes -4. Verify that the appropriate labels are applied and auto sign-off occurs - -## Limitations - -- The non-functional changes detection is conservative and may not catch all possible non-functional changes -- New JSON files are currently treated as functional changes for safety -- Array changes in JSON are treated conservatively and may be flagged as functional even if they're not -- The workflow requires all other auto sign-off requirements to be met (proper labels, passing checks, etc.) - -## Future Improvements - -Potential enhancements: - -1. More sophisticated JSON diff analysis -2. Support for additional file types -3. Configurable non-functional property lists -4. Better handling of complex JSON structure changes -5. Integration with OpenAPI/Swagger semantic analysis tools diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 8de16a08f26a..49a4b1c74b5e 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -79,11 +79,10 @@ function formatForLog(value) { /** * Analyzes a PR to determine what types of changes it contains - * @param {Pick & Partial>} args + * @param {{ core: typeof import("@actions/core"), context?: import("@actions/github-script").AsyncFunctionArguments["context"] }} args * @returns {Promise} JSON string with categorized change types */ -export default async function checkTrivialChanges(args) { - const { core } = args; +export default async function checkTrivialChanges({ core }) { // Create result object once at the top const changes = createEmptyPullRequestChanges(); @@ -110,14 +109,6 @@ export default async function checkTrivialChanges(args) { ), }; - // Early exit if no resource-manager changes - if (changedRmFiles.length === 0) { - core.info("No resource-manager changes detected in PR"); - core.info(`PR Changes: ${JSON.stringify(changes)}`); - core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); - return JSON.stringify(changes); - } - // Check if PR contains non-resource-manager changes const hasNonRmChanges = changedFiles.some((file) => !resourceManager(file)); if (hasNonRmChanges) { @@ -128,12 +119,22 @@ export default async function checkTrivialChanges(args) { core.info( `Non-RM files: ${nonRmFiles.slice(0, 5).join(", ")}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ""}`, ); + // This workflow reports resource-manager scoped change categories. + // Mark as non-trivial/blocked because the PR touches files outside resource-manager. changes.other = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); return JSON.stringify(changes); } + // Early exit if no resource-manager changes + if (changedRmFiles.length === 0) { + core.info("No resource-manager changes detected in PR"); + core.info(`PR Changes: ${JSON.stringify(changes)}`); + core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + return JSON.stringify(changes); + } + // Check for non-trivial file operations (additions, deletions, renames) const hasNonTrivialFileOperations = checkForNonTrivialFileOperations( changedRmFilesStatuses, @@ -142,7 +143,7 @@ export default async function checkTrivialChanges(args) { if (hasNonTrivialFileOperations) { core.info("Non-trivial file operations detected (new spec files, deletions, or renames)"); // These are functional changes by policy - changes.functional = true; + changes.rmFunctional = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); return JSON.stringify(changes); @@ -215,15 +216,15 @@ async function analyzePullRequestChanges(changedFiles, git, core, changes) { // Set flags for file types present if (documentationFiles.length > 0) { - changes.documentation = true; + changes.rmDocumentation = true; } if (exampleFiles.length > 0) { - changes.examples = true; + changes.rmExamples = true; } if (otherFiles.length > 0) { - changes.other = true; + changes.rmOther = true; } // Analyze spec files to determine if functional or non-functional @@ -253,7 +254,7 @@ async function analyzePullRequestChanges(changedFiles, git, core, changes) { } } - changes.functional = hasFunctionalChanges; + changes.rmFunctional = hasFunctionalChanges; } } diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index 2b593c18b811..e0516c7cde63 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -27,9 +27,10 @@ function isPullRequestChanges(value) { /** @type {Record} */ const obj = /** @type {Record} */ (value); return ( - typeof obj.documentation === "boolean" && - typeof obj.examples === "boolean" && - typeof obj.functional === "boolean" && + typeof obj.rmDocumentation === "boolean" && + typeof obj.rmExamples === "boolean" && + typeof obj.rmFunctional === "boolean" && + typeof obj.rmOther === "boolean" && typeof obj.other === "boolean" ); } @@ -79,9 +80,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: false, + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, other: false, }); }); @@ -106,9 +108,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: false, + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, other: true, }); }); @@ -132,10 +135,11 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: false, - other: false, // No RM files at all, so returns empty + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, + other: true, // Non-RM changes block ARM auto-signoff even when there are no RM changes }); }); @@ -156,9 +160,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, // New spec files are functional + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, // New spec files are functional + rmOther: false, other: false, }); }); @@ -180,9 +185,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, // Deleted spec files are functional + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, // Deleted spec files are functional + rmOther: false, other: false, }); }); @@ -205,9 +211,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, // Renamed spec files are functional + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, // Renamed spec files are functional + rmOther: false, other: false, }); }); @@ -230,9 +237,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: true, - examples: false, - functional: false, + rmDocumentation: true, + rmExamples: false, + rmFunctional: false, + rmOther: false, other: false, }); }); @@ -255,9 +263,36 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: true, - functional: false, + rmDocumentation: false, + rmExamples: true, + rmFunctional: false, + rmOther: false, + other: false, + }); + }); + + it("marks rmOther when other resource-manager files are changed", async () => { + const rmOtherFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/readme.md", + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/notes.txt", + ]; + + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: rmOtherFiles, + deletions: [], + renames: [], + total: 0, + }); + + const result = await checkTrivialChanges({ core, context }); + const parsed = parsePullRequestChanges(result); + + expect(parsed).toEqual({ + rmDocumentation: true, + rmExamples: false, + rmFunctional: false, + rmOther: true, other: false, }); }); @@ -304,9 +339,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: false, + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, other: false, }); @@ -354,9 +390,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, other: false, }); }); @@ -405,9 +442,10 @@ describe("checkTrivialChanges", () => { // Should detect functional changes, so overall not trivial expect(parsed).toEqual({ - documentation: true, - examples: true, - functional: true, + rmDocumentation: true, + rmExamples: true, + rmFunctional: true, + rmOther: false, other: false, }); }); @@ -441,9 +479,10 @@ describe("checkTrivialChanges", () => { // Should treat JSON parsing errors as non-trivial changes expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, other: false, }); }); @@ -468,9 +507,10 @@ describe("checkTrivialChanges", () => { // Should treat git errors as non-trivial changes expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, other: false, }); }); @@ -533,9 +573,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: false, + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, other: false, }); }); @@ -593,9 +634,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: false, - examples: false, - functional: true, + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, other: false, }); }); @@ -619,9 +661,10 @@ describe("checkTrivialChanges", () => { const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ - documentation: true, - examples: true, - functional: false, + rmDocumentation: true, + rmExamples: true, + rmFunctional: false, + rmOther: false, other: false, }); }); From 710184e8d83db31347a126448b2241523090eafd Mon Sep 17 00:00:00 2001 From: akhilailla Date: Fri, 19 Dec 2025 17:13:03 -0600 Subject: [PATCH 24/36] Refactor, updates based on PR comments --- .github/workflows/arm-auto-signoff-code.yaml | 18 +- .../workflows/arm-auto-signoff-status.yaml | 8 +- .../arm-auto-signoff/arm-auto-signoff-code.js | 55 +--- .../arm-auto-signoff-status.js | 271 +++++++----------- .../arm-incremental-typespec.js | 4 +- .../arm-auto-signoff/trivial-changes-check.js | 24 +- .github/workflows/src/label.js | 12 + 7 files changed, 161 insertions(+), 231 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index 5b4d0a46977c..fc17cc5c6241 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -43,15 +43,21 @@ jobs: name: ARM Auto SignOff - Analyze Code uses: actions/github-script@v8 with: - result-encoding: string + result-encoding: json script: | - const { default: armAutoSignoffCode } = + const { armAutoSignoffCode } = await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js'); return await armAutoSignoffCode({ github, context, core }); - # Upload artifact with all results - - name: Upload artifact - ARM Auto Signoff Code Results + # Upload artifacts with individual results + - name: Upload artifact - ARM Auto Signoff Code Result (incremental) uses: ./.github/actions/add-empty-artifact with: - name: "arm-auto-signoff-code-results" - value: ${{ steps.arm-auto-signoff-code.outputs.result }} + name: "incremental-typespec" + value: ${{ fromJSON(steps.arm-auto-signoff-code.outputs.result).incremental }} + + - name: Upload artifact - ARM Auto Signoff Code Result (trivial) + uses: ./.github/actions/add-empty-artifact + with: + name: "trivial-changes" + value: ${{ fromJSON(steps.arm-auto-signoff-code.outputs.result).trivial }} diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index ba8abe788746..f7ae2fc745c0 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -88,8 +88,8 @@ jobs: await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js'); return await getLabelAction({ github, context, core }); - # Add/remove specific auto sign-off labels based on analysis results. - # The action is explicit: 'add' | 'remove' | 'none'. + # Add/remove specific auto sign-off labels based on analysis results. + # The action is explicit: 'add' | 'remove' | 'none'. - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMSignedOff'] != 'none' name: Upload artifact for ARMSignedOff label uses: ./.github/actions/add-label-artifact @@ -103,14 +103,14 @@ jobs: with: name: "ARMAutoSignedOff-IncrementalTSP" value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] == 'add' }}" - + - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] != 'none' name: Upload artifact for ARMAutoSignedOff-Trivial-Test label uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-Trivial-Test" value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] == 'add' }}" - + # Required for consumers to identify the head SHA associated with this workflow run. # Output can be trusted, because it was uploaded from a workflow that is trusted, # because "issue_comment", and "workflow_run" only trigger on workflows in the default branch. diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index d6745246b362..a021792be024 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -1,29 +1,9 @@ -import incrementalTypeSpec from "./arm-incremental-typespec.js"; +import { incrementalTypeSpec } from "./arm-incremental-typespec.js"; import { isTrivialPullRequest } from "./pr-changes.js"; -import checkTrivialChanges from "./trivial-changes-check.js"; +import { checkTrivialChanges } from "./trivial-changes-check.js"; /** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ -/** - * @param {unknown} value - * @returns {value is PullRequestChanges} - */ -function isPullRequestChanges(value) { - if (typeof value !== "object" || value === null) { - return false; - } - - /** @type {Record} */ - const obj = /** @type {Record} */ (value); - - return ( - typeof obj.rmDocumentation === "boolean" && - typeof obj.rmExamples === "boolean" && - typeof obj.rmFunctional === "boolean" && - typeof obj.rmOther === "boolean" && - typeof obj.other === "boolean" - ); -} /** * Main entry point for ARM Auto Signoff Code workflow. @@ -34,43 +14,38 @@ function isPullRequestChanges(value) { * - Trivial changes check * * @param {import("@actions/github-script").AsyncFunctionArguments} args - * @returns {Promise} Key-value format string consumed by downstream workflow logic. + * @returns {Promise<{ incremental: boolean, trivial: boolean }>} JSON object consumed by downstream workflow logic. */ -export default async function armAutoSignoffCode(args) { - const { context, core } = args; +export async function armAutoSignoffCode(args) { + const { core } = args; core.info("Starting ARM Auto Signoff Code analysis."); // Run incremental TypeSpec check core.startGroup("Checking for incremental TypeSpec changes"); - const incrementalTypeSpecResult = await incrementalTypeSpec(args); + const incrementalTypeSpecResult = await incrementalTypeSpec(core); core.info(`Incremental TypeSpec result: ${incrementalTypeSpecResult}`); core.endGroup(); // Run trivial changes check core.startGroup("Checking for trivial changes"); - const trivialChangesResultString = await checkTrivialChanges({ core, context }); - /** @type {unknown} */ const parsedTrivialChanges = (JSON.parse(trivialChangesResultString)); - if (!isPullRequestChanges(parsedTrivialChanges)) { - throw new Error("Unexpected trivial changes result shape"); - } - const trivialChanges = parsedTrivialChanges; - core.info(`Trivial changes result: ${JSON.stringify(trivialChanges)}`); + const trivialChangesResult = await checkTrivialChanges(core); + core.info(`Trivial changes result: ${JSON.stringify(trivialChangesResult)}`); core.endGroup(); - // Determine if PR qualifies for auto sign-off - const isTrivial = isTrivialPullRequest(trivialChanges); - const qualifiesForAutoSignoff = incrementalTypeSpecResult || isTrivial; + const isTrivial = isTrivialPullRequest(trivialChangesResult); // Combine results const combined = { incrementalTypeSpec: incrementalTypeSpecResult, - trivialChanges: trivialChanges, - qualifiesForAutoSignoff: qualifiesForAutoSignoff, + trivialChanges: trivialChangesResult, }; core.info(`Combined result: ${JSON.stringify(combined, null, 2)}`); - // Return key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true - return `incrementalTypeSpec-${incrementalTypeSpecResult},isTrivial-${isTrivial},qualifies-${qualifiesForAutoSignoff}`; + // Return a JSON object for downstream workflow logic. + return { + incremental: incrementalTypeSpecResult, + trivial: isTrivial, + }; } diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 5d3a58e30cf5..ae922cda43a5 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -3,12 +3,13 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../../shared/src/github.js"; import { equals } from "../../../shared/src/set.js"; import { byDate, invert } from "../../../shared/src/sort.js"; import { extractInputs } from "../context.js"; -import { LabelAction } from "../label.js"; +import { ArmAutoSignoffLabel, LabelAction } from "../label.js"; -const LABEL_ARM_SIGNED_OFF = "ARMSignedOff"; -const LABEL_ARM_AUTO_SIGNED_OFF = "ARMAutoSignedOff"; -const LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP = "ARMAutoSignedOff-IncrementalTSP"; -const LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST = "ARMAutoSignedOff-Trivial-Test"; +/** @typedef {import('@octokit/plugin-rest-endpoint-methods').RestEndpointMethodTypes} RestEndpointMethodTypes */ +/** @typedef {RestEndpointMethodTypes["issues"]["listLabelsOnIssue"]["response"]["data"][number]} IssueLabel */ +/** @typedef {RestEndpointMethodTypes["actions"]["listWorkflowRunsForRepo"]["response"]["data"]["workflow_runs"][number]} WorkflowRun */ +/** @typedef {RestEndpointMethodTypes["repos"]["listCommitStatusesForRef"]["response"]["data"][number]} CommitStatus */ +/** @typedef {RestEndpointMethodTypes["actions"]["listWorkflowRunArtifacts"]["response"]["data"]["artifacts"][number]} Artifact */ /** * The workflow contract is intentionally a fixed set of keys. @@ -22,70 +23,29 @@ const LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST = "ARMAutoSignedOff-Trivial-Test"; /** @returns {ManagedLabelActions} */ function createNoneLabelActions() { return { - [LABEL_ARM_SIGNED_OFF]: LabelAction.None, - [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: LabelAction.None, - [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: LabelAction.None, + [ArmAutoSignoffLabel.ArmSignedOff]: LabelAction.None, + [ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP]: LabelAction.None, + [ArmAutoSignoffLabel.ArmAutoSignedOffTrivialTest]: LabelAction.None, }; } -/** @typedef {{ name: string }} IssueLabel */ - -/** - * Minimal subset of workflow run fields used by this workflow. - * @typedef {{ - * id: number, - * name: string, - * status: string, - * conclusion: string | null, - * updated_at: string, - * }} WorkflowRun - */ - -/** - * Minimal subset of commit status fields used by this workflow. - * @typedef {{ - * context: string, - * state: string, - * updated_at: string, - * }} CommitStatus - */ - -/** @typedef {{ name: string }} Artifact */ - /** - * @param {string} artifactName - * @returns {{ incrementalTypeSpec: boolean, isTrivial: boolean, qualifiesForAutoSignoff: boolean } | null} + * The analyze-code workflow uploads empty artifacts named `${name}=${value}`. + * We treat missing artifacts or non-boolean values as a failure (no auto-signoff). + * + * @param {string[]} artifactNames + * @param {string} key + * @returns {boolean | null} */ -function parseArmAnalysisArtifact(artifactName) { - const prefix = "arm-auto-signoff-code-results="; - if (!artifactName.startsWith(prefix)) { +function readBooleanArtifactValue(artifactNames, key) { + const prefix = `${key}=`; + const match = artifactNames.find((name) => name.startsWith(prefix)); + if (!match) { return null; } - const value = artifactName.substring(prefix.length); - - // Parse key-value format: incrementalTypeSpec-true,isTrivial-false,qualifies-true - // Split by comma, then parse each key-value pair. - /** @type {Record} */ - const pairs = Object.fromEntries( - value - .split(",") - .map((p) => p.trim()) - .filter(Boolean) - .map((p) => { - const lastDash = p.lastIndexOf("-"); - if (lastDash === -1) { - return [p, ""]; // will be treated as false - } - return [p.substring(0, lastDash), p.substring(lastDash + 1)]; - }), - ); - - return { - incrementalTypeSpec: pairs.incrementalTypeSpec === "true", - isTrivial: pairs.isTrivial === "true", - qualifiesForAutoSignoff: pairs.qualifies === "true", - }; + const value = match.substring(prefix.length).trim().toLowerCase(); + return value === "true" ? true : value === "false" ? false : null; } // TODO: Add tests @@ -119,45 +79,40 @@ export default async function getLabelAction({ github, context, core }) { * @returns {Promise<{headSha: string, issueNumber: number, labelActions: ManagedLabelActions}>} */ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, github, core }) { - // TODO: Try to extract labels from context (when available) to avoid unnecessary API call - // permissions: { issues: read, pull-requests: read } - const labels = /** @type {IssueLabel[]} */ ( - await github.paginate(github.rest.issues.listLabelsOnIssue, { - owner: owner, - repo: repo, - issue_number: issue_number, - per_page: PER_PAGE_MAX, - }) - ); - const labelNames = labels.map((label) => label.name); - /** @type {{headSha: string, issueNumber: number}} */ const baseResult = { headSha: head_sha, issueNumber: issue_number, }; - const labelActions = createNoneLabelActions(); + const noneLabelActions = createNoneLabelActions(); + + // TODO: Try to extract labels from context (when available) to avoid unnecessary API call + // permissions: { issues: read, pull-requests: read } + /** @type {IssueLabel[]} */ + const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: owner, + repo: repo, + issue_number: issue_number, + per_page: PER_PAGE_MAX, + }); + const labelNames = labels.map((label) => label.name); // Check if any auto sign-off labels are currently present // Only proceed with auto sign-off logic if auto labels exist or we're about to add them - const hasAutoSignedOffLabels = - labelNames.includes(LABEL_ARM_AUTO_SIGNED_OFF) || - labelNames.includes(LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP); - + const hasAutoSignedOffLabels = labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP); core.info(`Labels: ${inspect(labelNames)}`); core.info(`Has auto signed-off labels: ${hasAutoSignedOffLabels}`); // permissions: { actions: read } - const workflowRuns = /** @type {WorkflowRun[]} */ ( - await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { - owner, - repo, - event: "pull_request", - head_sha, - per_page: PER_PAGE_MAX, - }) - ); + /** @type {WorkflowRun[]} */ + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { + owner, + repo, + event: "pull_request", + head_sha, + per_page: PER_PAGE_MAX, + }); core.info("Workflow Runs:"); workflowRuns.forEach((wf) => { @@ -169,7 +124,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, const noneResult = { ...baseResult, - labelActions, + labelActions: noneLabelActions, }; const removeAutoSignedOffLabelsIfPresent = () => { @@ -180,10 +135,10 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { ...noneResult, labelActions: { - ...labelActions, - [LABEL_ARM_SIGNED_OFF]: LabelAction.Remove, - [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: LabelAction.Remove, - [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: LabelAction.Remove, + ...noneLabelActions, + [ArmAutoSignoffLabel.ArmSignedOff]: LabelAction.Remove, + [ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP]: LabelAction.Remove, + [ArmAutoSignoffLabel.ArmAutoSignedOffTrivialTest]: LabelAction.Remove, }, }; }; @@ -205,14 +160,13 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, } // permissions: { statuses: read } - const statuses = /** @type {CommitStatus[]} */ ( - await github.paginate(github.rest.repos.listCommitStatusesForRef, { - owner: owner, - repo: repo, - ref: head_sha, - per_page: PER_PAGE_MAX, - }) - ); + /** @type {CommitStatus[]} */ + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + owner: owner, + repo: repo, + ref: head_sha, + per_page: PER_PAGE_MAX, + }); core.info("Statuses:"); statuses.forEach((status) => { @@ -258,18 +212,17 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, requiredStatuses.every((status) => status.state === CommitStatusState.SUCCESS) ) { core.info("All requirements met for auto-signoff"); - - const autoIncremental = armAnalysisResult.incrementalTypeSpec; + const autoIncrementalTSP = armAnalysisResult.incrementalTypeSpec; const autoTrivialTest = armAnalysisResult.isTrivial; // Add ARMAutoSignOff label only when the PR is identified as an incremental typespec // As the trivial changes sign-off is being released in test mode - const autoSignOffAction = autoIncremental + const armSignOffAction = autoIncrementalTSP ? LabelAction.Add : hasAutoSignedOffLabels ? LabelAction.Remove : LabelAction.None; - const autoIncrementalAction = autoIncremental ? LabelAction.Add : LabelAction.Remove; + const autoIncrementalTSPAction = autoIncrementalTSP ? LabelAction.Add : LabelAction.Remove; const testTrivialAction = autoTrivialTest ? LabelAction.Add : LabelAction.Remove; // Keep labels in sync with current analysis results. @@ -277,10 +230,10 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, return { ...baseResult, labelActions: { - ...labelActions, - [LABEL_ARM_SIGNED_OFF]: autoSignOffAction, - [LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP]: autoIncrementalAction, - [LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST]: testTrivialAction, + ...noneLabelActions, + [ArmAutoSignoffLabel.ArmSignedOff]: armSignOffAction, + [ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP]: autoIncrementalTSPAction, + [ArmAutoSignoffLabel.ArmAutoSignedOffTrivialTest]: testTrivialAction, }, }; } @@ -295,11 +248,11 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, * Check ARM Analysis workflow results (combines incremental TypeSpec and trivial changes). * * @param {WorkflowRun[]} workflowRuns - * @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-script').AsyncFunctionArguments['github']} github * @param {string} owner * @param {string} repo - * @param {typeof import("@actions/core")} core - * @returns {Promise<{qualifiesForAutoSignoff: boolean, incrementalTypeSpec: boolean, isTrivial: boolean, reason: string}>} + * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core + * @returns {Promise<{qualifiesForAutoSignoff: boolean, incrementalTypeSpec: boolean, isTrivial: boolean}>} */ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) { const wfName = "ARM Auto SignOff - Analyze Code"; @@ -316,7 +269,6 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) qualifiesForAutoSignoff: false, incrementalTypeSpec: false, isTrivial: false, - reason: `No '${wfName}' workflow runs found`, }; } @@ -329,7 +281,6 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) qualifiesForAutoSignoff: false, incrementalTypeSpec: false, isTrivial: false, - reason: `'${wfName}' workflow still in progress`, }; } @@ -339,77 +290,61 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) qualifiesForAutoSignoff: false, incrementalTypeSpec: false, isTrivial: false, - reason: `'${wfName}' workflow failed`, }; } // permissions: { actions: read } - const artifacts = /** @type {Artifact[]} */ ( - await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: PER_PAGE_MAX, - }) - ); + /** @type {Artifact[]} */ + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: PER_PAGE_MAX, + }); + /** @type {string[]} */ const artifactNames = artifacts.map((a) => a.name); - core.info(`${wfName} artifactNames: ${JSON.stringify(artifactNames)}`); - /** @type {{ incrementalTypeSpec: boolean, isTrivial: boolean, qualifiesForAutoSignoff: boolean } | null} */ - const parsed = artifactNames.map(parseArmAnalysisArtifact).find((r) => r !== null) ?? null; - - if (parsed) { - const { incrementalTypeSpec, isTrivial, qualifiesForAutoSignoff } = parsed; - - core.info( - `ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}, qualifiesForAutoSignoff=${qualifiesForAutoSignoff}`, - ); + const incrementalTypeSpec = readBooleanArtifactValue(artifactNames, "incremental-typespec"); + const isTrivial = readBooleanArtifactValue(artifactNames, "trivial-changes"); - if (!qualifiesForAutoSignoff) { - core.info("PR does not qualify for auto sign-off based on ARM analysis"); - return { - qualifiesForAutoSignoff: false, - incrementalTypeSpec, - isTrivial, - reason: "No qualifying changes detected", - }; - } + // ARM-Auto-SignOff-Code uploads 2 distinct artifacts. + // If either artifact is missing (or invalid), fail closed: no auto-signoff. + if (incrementalTypeSpec === null || isTrivial === null) { + const missing = [ + incrementalTypeSpec === null ? "incremental-typespec" : null, + isTrivial === null ? "trivial-changes" : null, + ] + .filter(Boolean) + .join(", "); - if (!incrementalTypeSpec && !isTrivial) { - core.warning( - `Invalid ARM analysis result: qualifies=true but both incrementalTypeSpec and isTrivial are false.`, - ); - return { - qualifiesForAutoSignoff: false, - incrementalTypeSpec, - isTrivial, - reason: "Invalid qualifying result", - }; - } + core.info(`Missing/invalid ARM analysis artifact(s): ${missing}`); + return { + qualifiesForAutoSignoff: false, + incrementalTypeSpec: false, + isTrivial: false, + }; + } - const reason = [ - incrementalTypeSpec ? LABEL_AUTO_SIGNED_OFF_INCREMENTAL_TSP : null, - isTrivial ? LABEL_AUTO_SIGNED_OFF_TRIVIAL_TEST : null, - ] - .filter(Boolean) - .join(", "); + core.info( + `ARM analysis results: incrementalTypeSpec=${incrementalTypeSpec}, isTrivial=${isTrivial}`, + ); - core.info(`PR qualifies for auto sign-off: ${reason}`); + const qualifiesForAutoSignoff = incrementalTypeSpec || isTrivial; + if (!qualifiesForAutoSignoff) { + core.info("PR does not qualify for auto sign-off based on ARM analysis"); return { - qualifiesForAutoSignoff: true, - incrementalTypeSpec, - isTrivial, - reason, + qualifiesForAutoSignoff: false, + incrementalTypeSpec: false, + isTrivial: false, }; } - // If we get here, no combined artifact found - treat as not qualifying + core.info(`PR qualifies for auto sign-off based on ARM analysis.`); return { - qualifiesForAutoSignoff: false, - incrementalTypeSpec: false, - isTrivial: false, - reason: "No combined analysis artifact found", + qualifiesForAutoSignoff: true, + incrementalTypeSpec: incrementalTypeSpec, + isTrivial: isTrivial, }; } \ No newline at end of file diff --git a/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js b/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js index f69cef97efb5..40e95fe53457 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js +++ b/.github/workflows/src/arm-auto-signoff/arm-incremental-typespec.js @@ -17,10 +17,10 @@ import { CoreLogger } from "../core-logger.js"; debug.enable("simple-git"); /** - * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments + * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core * @returns {Promise} */ -export default async function incrementalTypeSpec({ core }) { +export async function incrementalTypeSpec(core) { const options = { cwd: process.env.GITHUB_WORKSPACE, paths: ["specification"], diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 49a4b1c74b5e..fc42bba2d247 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -11,6 +11,8 @@ import { import { CoreLogger } from "../core-logger.js"; import { createEmptyPullRequestChanges, isTrivialPullRequest } from "./pr-changes.js"; +/** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ + // Enable simple-git debug logging to improve console output debug.enable("simple-git"); @@ -79,10 +81,10 @@ function formatForLog(value) { /** * Analyzes a PR to determine what types of changes it contains - * @param {{ core: typeof import("@actions/core"), context?: import("@actions/github-script").AsyncFunctionArguments["context"] }} args - * @returns {Promise} JSON string with categorized change types + * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core + * @returns {Promise} Object with categorized change types */ -export default async function checkTrivialChanges({ core }) { +export async function checkTrivialChanges(core) { // Create result object once at the top const changes = createEmptyPullRequestChanges(); @@ -124,7 +126,7 @@ export default async function checkTrivialChanges({ core }) { changes.other = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); - return JSON.stringify(changes); + return changes; } // Early exit if no resource-manager changes @@ -132,7 +134,7 @@ export default async function checkTrivialChanges({ core }) { core.info("No resource-manager changes detected in PR"); core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); - return JSON.stringify(changes); + return changes; } // Check for non-trivial file operations (additions, deletions, renames) @@ -146,7 +148,7 @@ export default async function checkTrivialChanges({ core }) { changes.rmFunctional = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); - return JSON.stringify(changes); + return changes; } // Analyze what types of changes are present and update the changes object @@ -155,13 +157,13 @@ export default async function checkTrivialChanges({ core }) { core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); - return JSON.stringify(changes); + return changes; } /** * Checks for non-trivial file operations: additions/deletions of spec files, or any renames * @param {{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[]}} changedFilesStatuses - File status information - * @param {typeof import("@actions/core")} core - Core logger + * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger * @returns {boolean} - True if non-trivial operations detected */ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { @@ -199,7 +201,7 @@ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { * Analyzes a PR to determine what types of changes it contains and updates the changes object * @param {string[]} changedFiles - Array of changed file paths * @param {import('simple-git').SimpleGit} git - Git instance - * @param {typeof import("@actions/core")} core - Core logger + * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger * @param {import('./pr-changes.js').PullRequestChanges} changes - Changes object to update * @returns {Promise} */ @@ -263,7 +265,7 @@ async function analyzePullRequestChanges(changedFiles, git, core, changes) { * Note: New files and deletions should already be filtered by checkForNonTrivialFileOperations * @param {string} file - The file path * @param {import('simple-git').SimpleGit} git - Git instance - * @param {typeof import("@actions/core")} core - Core logger + * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger * @returns {Promise} - True if changes are non-functional only, false if any functional changes detected */ async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { @@ -339,7 +341,7 @@ function getFlatChangedFilesFromStatuses(statuses) { * @param {unknown} baseObj - Base object * @param {unknown} headObj - Head object * @param {string} path - Current path in the object - * @param {typeof import("@actions/core")} core - Core logger + * * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger * @returns {boolean} - True if all differences are non-functional */ function analyzeJsonDifferences(baseObj, headObj, path, core) { diff --git a/.github/workflows/src/label.js b/.github/workflows/src/label.js index c2dd54dfa3d3..2abaa1cd21de 100644 --- a/.github/workflows/src/label.js +++ b/.github/workflows/src/label.js @@ -7,3 +7,15 @@ export const LabelAction = Object.freeze({ Add: "add", Remove: "remove", }); + +/** + * ARM auto-signoff label names. + * @readonly + * @enum {string} + */ +export const ArmAutoSignoffLabel = Object.freeze({ + ArmSignedOff: "ARMSignedOff", + ArmAutoSignedOff: "ARMAutoSignedOff", + ArmAutoSignedOffIncrementalTSP: "ARMAutoSignedOff-IncrementalTSP", + ArmAutoSignedOffTrivialTest: "ARMAutoSignedOff-Trivial-Test", +}); \ No newline at end of file From 55ac6a56a2e70359f1dda2ea7fd48eb5646c6cce Mon Sep 17 00:00:00 2001 From: akhilailla Date: Tue, 6 Jan 2026 16:35:22 -0600 Subject: [PATCH 25/36] Refactor pr-changes from struct to class, update the use cases, tests --- .../arm-auto-signoff/arm-auto-signoff-code.js | 3 +- .../src/arm-auto-signoff/pr-changes.js | 130 ++++++++++-------- .../arm-auto-signoff/trivial-changes-check.js | 79 +++-------- .../arm-auto-signoff-status.test.js | 36 ++++- .../arm-incremental-typespec.test.js | 39 ++---- .../trivial-changes-check.test.js | 44 +++--- 6 files changed, 163 insertions(+), 168 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index a021792be024..c8806065dc94 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -1,5 +1,4 @@ import { incrementalTypeSpec } from "./arm-incremental-typespec.js"; -import { isTrivialPullRequest } from "./pr-changes.js"; import { checkTrivialChanges } from "./trivial-changes-check.js"; /** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ @@ -33,7 +32,7 @@ export async function armAutoSignoffCode(args) { core.info(`Trivial changes result: ${JSON.stringify(trivialChangesResult)}`); core.endGroup(); - const isTrivial = isTrivialPullRequest(trivialChangesResult); + const isTrivial = trivialChangesResult.isTrivial(); // Combine results const combined = { diff --git a/.github/workflows/src/arm-auto-signoff/pr-changes.js b/.github/workflows/src/arm-auto-signoff/pr-changes.js index ab4b5f661f98..f1e9dc8ba87b 100644 --- a/.github/workflows/src/arm-auto-signoff/pr-changes.js +++ b/.github/workflows/src/arm-auto-signoff/pr-changes.js @@ -1,10 +1,10 @@ /** - * Represents the types of changes present in a pull request. + * JSON representation of the change flags. * * All properties are boolean flags that indicate presence of a change type. * An empty PR would have all properties set to false. * - * @typedef {Object} PullRequestChanges + * @typedef {Object} PullRequestChangesJSON * @property {boolean} rmDocumentation - True if PR contains resource-manager scoped documentation (.md) changes * @property {boolean} rmExamples - True if PR contains resource-manager scoped example changes (/examples/*.json) * @property {boolean} rmFunctional - True if PR contains resource-manager scoped functional spec changes (API-impacting) @@ -13,67 +13,77 @@ */ /** - * Creates a PullRequestChanges object with all flags set to false - * @returns {PullRequestChanges} + * Represents the types of changes present in a pull request. */ -export function createEmptyPullRequestChanges() { - return { - rmDocumentation: false, - rmExamples: false, - rmFunctional: false, - rmOther: false, - other: false, - }; -} +export class PullRequestChanges { + /** @type {boolean} */ + rmDocumentation = false; -/** - * Checks if a PR qualifies as trivial based on its changes - * A PR is trivial if it contains only: - * - Documentation changes - * - Example changes - * And does NOT contain: - * - Functional spec changes - * - Other file types - * - * @param {PullRequestChanges} changes - The PR changes object - * @returns {boolean} - True if PR is trivial - */ -export function isTrivialPullRequest(changes) { - // Trivial if no functional changes and no other files - // Must have at least one of: documentation, examples - const hasNoBlockingChanges = - !changes.rmFunctional && !changes.rmOther && !changes.other; - const hasTrivialChanges = changes.rmDocumentation || changes.rmExamples; + /** @type {boolean} */ + rmExamples = false; - return hasNoBlockingChanges && hasTrivialChanges; -} + /** @type {boolean} */ + rmFunctional = false; -/** - * Checks if a PR contains only documentation changes - * @param {PullRequestChanges} changes - The PR changes object - * @returns {boolean} - True if only documentation changed - */ -export function isDocumentationOnly(changes) { - return ( - changes.rmDocumentation && - !changes.rmExamples && - !changes.rmFunctional && - !changes.rmOther && - !changes.other - ); -} + /** @type {boolean} */ + rmOther = false; -/** - * Checks if a PR contains only example changes - * @param {PullRequestChanges} changes - The PR changes object - * @returns {boolean} - True if only examples changed - */ -export function isExamplesOnly(changes) { - return ( - !changes.rmDocumentation && - changes.rmExamples && - !changes.rmFunctional && - !changes.rmOther && - !changes.other - ); + /** @type {boolean} */ + other = false; + + /** + * A PR is trivial if it contains only: + * - Documentation changes + * - Example changes + * and does NOT contain: + * - Functional spec changes + * - Other file types + * + * @returns {boolean} + */ + isTrivial() { + const hasNoBlockingChanges = !this.rmFunctional && !this.rmOther && !this.other; + const hasTrivialChanges = this.rmDocumentation || this.rmExamples; + return hasNoBlockingChanges && hasTrivialChanges; + } + + /** + * @returns {boolean} + */ + isDocumentationOnly() { + return ( + this.rmDocumentation && + !this.rmExamples && + !this.rmFunctional && + !this.rmOther && + !this.other + ); + } + + /** + * @returns {boolean} + */ + isExamplesOnly() { + return ( + !this.rmDocumentation && + this.rmExamples && + !this.rmFunctional && + !this.rmOther && + !this.other + ); + } + + /** + * Ensure stable JSON output even though this is a class. + * @returns {PullRequestChangesJSON} + */ + toJSON() { + return { + rmDocumentation: this.rmDocumentation, + rmExamples: this.rmExamples, + rmFunctional: this.rmFunctional, + rmOther: this.rmOther, + other: this.other, + }; + } } diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index fc42bba2d247..70fa5c93c1c2 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -1,6 +1,7 @@ // For now, treat all paths as posix, since this is the format returned from git commands import debug from "debug"; import { simpleGit } from "simple-git"; +import { inspect } from "util"; import { example, getChangedFilesStatuses, @@ -9,7 +10,7 @@ import { resourceManager, } from "../../../shared/src/changed-files.js"; import { CoreLogger } from "../core-logger.js"; -import { createEmptyPullRequestChanges, isTrivialPullRequest } from "./pr-changes.js"; +import { PullRequestChanges } from "./pr-changes.js"; /** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ @@ -34,51 +35,6 @@ const NON_FUNCTIONAL_PROPERTIES = new Set([ "tags", ]); -/** - * @param {unknown} error - * @returns {string} - */ -function getErrorMessage(error) { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -/** - * @param {unknown} value - * @returns {value is Record} - */ -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * @param {string} text - * @returns {unknown} - */ -function parseJsonUnknown(text) { - return /** @type {unknown} */ (JSON.parse(text)); -} - -/** - * @param {unknown} value - * @returns {string} - */ -function formatForLog(value) { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - if (value === null) return "null"; - if (value === undefined) return "undefined"; - try { - return JSON.stringify(value); - } catch { - return "[unserializable]"; - } -} - /** * Analyzes a PR to determine what types of changes it contains * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core @@ -86,7 +42,7 @@ function formatForLog(value) { */ export async function checkTrivialChanges(core) { // Create result object once at the top - const changes = createEmptyPullRequestChanges(); + const changes = new PullRequestChanges(); // Compare the pull request merge commit (HEAD) against its first parent (HEAD^). core.info("Comparing against base commit: HEAD^"); @@ -125,7 +81,7 @@ export async function checkTrivialChanges(core) { // Mark as non-trivial/blocked because the PR touches files outside resource-manager. changes.other = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); - core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + core.info(`Is trivial: ${changes.isTrivial()}`); return changes; } @@ -133,7 +89,7 @@ export async function checkTrivialChanges(core) { if (changedRmFiles.length === 0) { core.info("No resource-manager changes detected in PR"); core.info(`PR Changes: ${JSON.stringify(changes)}`); - core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + core.info(`Is trivial: ${changes.isTrivial()}`); return changes; } @@ -147,7 +103,7 @@ export async function checkTrivialChanges(core) { // These are functional changes by policy changes.rmFunctional = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); - core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + core.info(`Is trivial: ${changes.isTrivial()}`); return changes; } @@ -155,7 +111,7 @@ export async function checkTrivialChanges(core) { await analyzePullRequestChanges(changedRmFiles, git, core, changes); core.info(`PR Changes: ${JSON.stringify(changes)}`); - core.info(`Is trivial: ${isTrivialPullRequest(changes)}`); + core.info(`Is trivial: ${changes.isTrivial()}`); return changes; } @@ -249,7 +205,7 @@ async function analyzePullRequestChanges(changedFiles, git, core, changes) { break; } } catch (error) { - core.warning(`Failed to analyze ${file}: ${getErrorMessage(error)}`); + core.warning(`Failed to analyze ${file}: ${inspect(error)}`); // On error, treat as functional to be conservative hasFunctionalChanges = true; break; @@ -299,10 +255,10 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { let baseJson, headJson; try { - baseJson = parseJsonUnknown(baseContent); - headJson = parseJsonUnknown(headContent); + baseJson = /** @type {unknown} */ (JSON.parse(baseContent)); + headJson = /** @type {unknown} */ (JSON.parse(headContent)); } catch (error) { - core.warning(`Failed to parse JSON for ${file}: ${getErrorMessage(error)}`); + core.warning(`Failed to parse JSON for ${file}: ${inspect(error)}`); return false; } @@ -386,7 +342,14 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { } // Handle objects - if (isRecord(baseObj) && isRecord(headObj)) { + if ( + typeof baseObj === "object" && + baseObj !== null && + !Array.isArray(baseObj) && + typeof headObj === "object" && + headObj !== null && + !Array.isArray(headObj) + ) { const baseKeys = new Set(Object.keys(baseObj)); const headKeys = new Set(Object.keys(headObj)); @@ -436,12 +399,12 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { const currentProperty = path.split(".").pop() || path.split("[")[0]; if (NON_FUNCTIONAL_PROPERTIES.has(currentProperty)) { core.info( - `Value changed at ${path || "root"} (non-functional): "${formatForLog(baseObj)}" -> "${formatForLog(headObj)}"`, + `Value changed at ${path || "root"} (non-functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, ); return true; } core.info( - `Value changed at ${path || "root"} (functional): "${formatForLog(baseObj)}" -> "${formatForLog(headObj)}"`, + `Value changed at ${path || "root"} (functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, ); return false; } diff --git a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js index 66f4f3dbf9ac..7a3b8b216c3c 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js @@ -76,7 +76,7 @@ function createSuccessResult({ headSha, issueNumber, incrementalTypeSpec, isTriv * @param {Object} param0 * @param {boolean} param0.incrementalTypeSpec */ -function createMockGithub({ incrementalTypeSpec }) { +function createMockGithub({ incrementalTypeSpec, isTrivial = false }) { const github = createMockGithubBase(); github.rest.actions.listWorkflowRunsForRepo.mockResolvedValue({ @@ -98,14 +98,17 @@ function createMockGithub({ incrementalTypeSpec }) { }, }); - // Return artifact in the format: arm-auto-signoff-code-results=incrementalTypeSpec-VALUE,isTrivial-VALUE,qualifies-VALUE + // Analyze-code uploads 2 empty artifacts named `${name}=${value}`. const incremental = incrementalTypeSpec ? "true" : "false"; - const qualifies = incrementalTypeSpec ? "true" : "false"; + const trivial = isTrivial ? "true" : "false"; github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ data: { artifacts: [ { - name: `arm-auto-signoff-code-results=incrementalTypeSpec-${incremental},isTrivial-false,qualifies-${qualifies}`, + name: `incremental-typespec=${incremental}`, + }, + { + name: `trivial-changes=${trivial}`, }, ], }, @@ -175,6 +178,31 @@ describe("getLabelActionImpl", () => { ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }); + it("removes label if analysis artifacts missing (fail closed)", async () => { + const github = createMockGithub({ incrementalTypeSpec: true }); + github.rest.issues.listLabelsOnIssue.mockResolvedValue({ + data: [{ name: "ARMAutoSignedOff-IncrementalTSP" }], + }); + + // Only one of the required artifacts is present. + github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ + data: { + artifacts: [{ name: "incremental-typespec=true" }], + }, + }); + + await expect( + getLabelActionImpl({ + owner: "TestOwner", + repo: "TestRepo", + issue_number: 123, + head_sha: "abc123", + github: github, + core: core, + }), + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); + }); + it("no-ops if incremental typespec in progress", async () => { const github = createMockGithubBase(); github.rest.actions.listWorkflowRunsForRepo.mockResolvedValue({ diff --git a/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js b/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js index 0752abd86cd1..6bf46d7446ab 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js @@ -19,33 +19,24 @@ import { swaggerHandWritten, swaggerTypeSpecGenerated, } from "../../../shared/test/examples.js"; -import incrementalTypeSpecImpl from "../../src/arm-auto-signoff/arm-incremental-typespec.js"; +import { incrementalTypeSpec } from "../../src/arm-auto-signoff/arm-incremental-typespec.js"; import { createMockCore } from "../mocks.js"; const core = createMockCore(); -/** - * @param {unknown} asyncFunctionArgs - */ -function incrementalTypeSpec(asyncFunctionArgs) { - return incrementalTypeSpecImpl( - /** @type {import("@actions/github-script").AsyncFunctionArguments} */ (asyncFunctionArgs), - ); -} - describe("incrementalTypeSpec", () => { afterEach(() => { vi.clearAllMocks(); }); it("rejects if inputs null", async () => { - await expect(incrementalTypeSpec({})).rejects.toThrow(); + await expect(incrementalTypeSpec(undefined)).rejects.toThrow(); }); it("returns false if no changed RM files", async () => { vi.spyOn(changedFiles, "getChangedFiles").mockResolvedValue([]); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); }); it("returns false if a changed file is not typespec-generated", async () => { @@ -56,7 +47,7 @@ describe("incrementalTypeSpec", () => { const showSpy = vi.mocked(mockShow).mockResolvedValue(swaggerHandWritten); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toBeCalledWith([`HEAD:${swaggerPath}`]); }); @@ -72,7 +63,7 @@ describe("incrementalTypeSpec", () => { // "git ls-tree" returns "" if the spec folder doesn't exist in the base branch const rawSpy = vi.mocked(mockRaw).mockResolvedValue(""); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toBeCalledWith([`HEAD:${swaggerPath}`]); @@ -89,7 +80,7 @@ describe("incrementalTypeSpec", () => { .mocked(mockShow) .mockRejectedValue(new Error("path contoso.json does not exist in 'HEAD'")); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toBeCalledWith([`HEAD:${swaggerPath}`]); }); @@ -103,7 +94,7 @@ describe("incrementalTypeSpec", () => { .mocked(mockShow) .mockRejectedValue(new Error("path readme.md does not exist in 'HEAD'")); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toBeCalledWith([`HEAD:${readmePath}`]); }); @@ -115,7 +106,7 @@ describe("incrementalTypeSpec", () => { const showSpy = vi.mocked(mockShow).mockResolvedValue(""); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toBeCalledWith([`HEAD:${readmePath}`]); }); @@ -128,7 +119,7 @@ describe("incrementalTypeSpec", () => { const showSpy = vi.mocked(mockShow).mockResolvedValue("not } valid { json"); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toBeCalledWith([`HEAD:${swaggerPath}`]); }); @@ -166,7 +157,7 @@ describe("incrementalTypeSpec", () => { const lsTreeSpy = vi.mocked(mockRaw).mockResolvedValue(swaggerPath); - await expect(incrementalTypeSpec({ core })).resolves.toBe(false); + await expect(incrementalTypeSpec(core)).resolves.toBe(false); expect(showSpy).toHaveBeenCalledWith([`HEAD:${swaggerPath}`]); expect(showSpy).toHaveBeenCalledWith([`HEAD^:${swaggerPath}`]); @@ -182,7 +173,7 @@ describe("incrementalTypeSpec", () => { const showSpy = vi.mocked(mockShow).mockRejectedValue("string error"); - await expect(incrementalTypeSpec({ core })).rejects.toThrowError(); + await expect(incrementalTypeSpec(core)).rejects.toThrowError(); expect(showSpy).toBeCalledWith([`HEAD:${swaggerPath}`]); }); @@ -194,7 +185,7 @@ describe("incrementalTypeSpec", () => { const showSpy = vi.mocked(mockShow).mockRejectedValue("string error"); - await expect(incrementalTypeSpec({ core })).rejects.toThrowError(); + await expect(incrementalTypeSpec(core)).rejects.toThrowError(); expect(showSpy).toBeCalledWith([`HEAD:${readmePath}`]); }); @@ -209,7 +200,7 @@ describe("incrementalTypeSpec", () => { const lsTreeSpy = vi.mocked(mockRaw).mockResolvedValue(swaggerPath); - await expect(incrementalTypeSpec({ core })).resolves.toBe(true); + await expect(incrementalTypeSpec(core)).resolves.toBe(true); expect(showSpy).toBeCalledWith([`HEAD:${swaggerPath}`]); expect(showSpy).toBeCalledWith([`HEAD^:${swaggerPath}`]); @@ -260,7 +251,7 @@ describe("incrementalTypeSpec", () => { const lsTreeSpy = vi.mocked(mockRaw).mockResolvedValue(swaggerPath); - await expect(incrementalTypeSpec({ core })).resolves.toBe(true); + await expect(incrementalTypeSpec(core)).resolves.toBe(true); expect(showSpy).toBeCalledWith([`HEAD:${readmePath}`]); expect(showSpy).toBeCalledWith([`HEAD^:${swaggerPath}`]); @@ -286,7 +277,7 @@ describe("incrementalTypeSpec", () => { const lsTreeSpy = vi.mocked(mockRaw).mockResolvedValue(swaggerPath); - await expect(incrementalTypeSpec({ core })).resolves.toBe(true); + await expect(incrementalTypeSpec(core)).resolves.toBe(true); expect(showSpy).toBeCalledWith([`HEAD^:${swaggerPath}`]); diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index e0516c7cde63..8483bf96e5ad 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -8,7 +8,7 @@ vi.mock("simple-git", () => ({ import * as simpleGit from "simple-git"; import * as changedFiles from "../../../shared/src/changed-files.js"; -import checkTrivialChanges from "../../src/arm-auto-signoff/trivial-changes-check.js"; +import { checkTrivialChanges } from "../../src/arm-auto-signoff/trivial-changes-check.js"; import { createMockContext, createMockCore } from "../mocks.js"; const core = createMockCore(); @@ -36,10 +36,14 @@ function isPullRequestChanges(value) { } /** - * @param {string} jsonText + * Accept either the JSON string form (workflow output) or the object instance returned + * by the checker (which implements `toJSON`). + * + * @param {unknown} value * @returns {PullRequestChanges} */ -function parsePullRequestChanges(jsonText) { +function parsePullRequestChanges(value) { + const jsonText = typeof value === "string" ? value : JSON.stringify(value); const parsed = /** @type {unknown} */ (JSON.parse(jsonText)); if (!isPullRequestChanges(parsed)) { throw new Error("Unexpected PullRequestChanges JSON shape"); @@ -76,7 +80,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -104,7 +108,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -131,7 +135,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -156,7 +160,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -181,7 +185,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -207,7 +211,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -233,7 +237,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -259,7 +263,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -285,7 +289,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -335,7 +339,7 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -386,7 +390,7 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -437,7 +441,7 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); // Should detect functional changes, so overall not trivial @@ -474,7 +478,7 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); // Should treat JSON parsing errors as non-trivial changes @@ -502,7 +506,7 @@ describe("checkTrivialChanges", () => { getMockGit().show.mockRejectedValue(new Error("Git operation failed")); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); // Should treat git errors as non-trivial changes @@ -569,7 +573,7 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -630,7 +634,7 @@ describe("checkTrivialChanges", () => { return Promise.reject(new Error("does not exist")); }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ @@ -657,7 +661,7 @@ describe("checkTrivialChanges", () => { total: 0, }); - const result = await checkTrivialChanges({ core, context }); + const result = await checkTrivialChanges(core); const parsed = parsePullRequestChanges(result); expect(parsed).toEqual({ From 8b5b83a8e767702b70f42064d61b52761f66d29c Mon Sep 17 00:00:00 2001 From: akhilailla Date: Wed, 7 Jan 2026 15:16:33 -0600 Subject: [PATCH 26/36] Refactor naming, Fix lint errors --- .../arm-auto-signoff/trivial-changes-check.js | 84 +++++++++++-------- .../arm-auto-signoff-status.test.js | 5 +- .../arm-incremental-typespec.test.js | 14 ++-- .../trivial-changes-check.test.js | 14 ++-- 4 files changed, 64 insertions(+), 53 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 70fa5c93c1c2..2a63f2814c0c 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -5,14 +5,14 @@ import { inspect } from "util"; import { example, getChangedFilesStatuses, - json, markdown, resourceManager, + swagger } from "../../../shared/src/changed-files.js"; import { CoreLogger } from "../core-logger.js"; import { PullRequestChanges } from "./pr-changes.js"; -/** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ +/** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChangesType */ // Enable simple-git debug logging to improve console output debug.enable("simple-git"); @@ -38,7 +38,7 @@ const NON_FUNCTIONAL_PROPERTIES = new Set([ /** * Analyzes a PR to determine what types of changes it contains * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - * @returns {Promise} Object with categorized change types + * @returns {Promise} Object with categorized change types */ export async function checkTrivialChanges(core) { // Create result object once at the top @@ -53,7 +53,7 @@ export async function checkTrivialChanges(core) { }; const changedFilesStatuses = await getChangedFilesStatuses(options); - const changedFiles = getFlatChangedFilesFromStatuses(changedFilesStatuses); + const changedFiles = getFlattenedChangedFilesFromStatuses(changedFilesStatuses); const git = simpleGit(options.cwd); // Filter to only resource-manager files for ARM auto-signoff @@ -93,13 +93,14 @@ export async function checkTrivialChanges(core) { return changes; } - // Check for non-trivial file operations (additions, deletions, renames) - const hasNonTrivialFileOperations = checkForNonTrivialFileOperations( + // Check for significant file operations (additions, deletions, renames) + const hasSignificantFileOperationsInPr = hasSignificantFileOperations( changedRmFilesStatuses, core, ); - if (hasNonTrivialFileOperations) { - core.info("Non-trivial file operations detected (new spec files, deletions, or renames)"); + + if (hasSignificantFileOperationsInPr) { + core.info("Significant file operations detected (new spec files, deletions, or renames)"); // These are functional changes by policy changes.rmFunctional = true; core.info(`PR Changes: ${JSON.stringify(changes)}`); @@ -108,7 +109,7 @@ export async function checkTrivialChanges(core) { } // Analyze what types of changes are present and update the changes object - await analyzePullRequestChanges(changedRmFiles, git, core, changes); + await analyzeAndUpdatePullRequestChanges(changedRmFiles, git, core, changes); core.info(`PR Changes: ${JSON.stringify(changes)}`); core.info(`Is trivial: ${changes.isTrivial()}`); @@ -117,34 +118,30 @@ export async function checkTrivialChanges(core) { } /** - * Checks for non-trivial file operations: additions/deletions of spec files, or any renames + * Checks for significant file operations: additions/deletions of spec files, or any renames * @param {{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[]}} changedFilesStatuses - File status information * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger - * @returns {boolean} - True if non-trivial operations detected + * @returns {boolean} - True if significant operations detected */ -function checkForNonTrivialFileOperations(changedFilesStatuses, core) { +function hasSignificantFileOperations(changedFilesStatuses, core) { // New spec files (non-example JSON) are non-trivial - const newSpecFiles = changedFilesStatuses.additions.filter( - (file) => json(file) && !example(file) && !markdown(file), - ); + const newSpecFiles = changedFilesStatuses.additions.filter(swagger); if (newSpecFiles.length > 0) { - core.info(`Non-trivial: New spec files detected: ${newSpecFiles.join(", ")}`); + core.info(`Significant: New spec files detected: ${newSpecFiles.join(", ")}`); return true; } // Deleted spec files (non-example JSON) are non-trivial - const deletedSpecFiles = changedFilesStatuses.deletions.filter( - (file) => json(file) && !example(file) && !markdown(file), - ); + const deletedSpecFiles = changedFilesStatuses.deletions.filter(swagger); if (deletedSpecFiles.length > 0) { - core.info(`Non-trivial: Deleted spec files detected: ${deletedSpecFiles.join(", ")}`); + core.info(`Significant: Deleted spec files detected: ${deletedSpecFiles.join(", ")}`); return true; } // Any file renames/moves are non-trivial (conservative approach) if (changedFilesStatuses.renames.length > 0) { core.info( - `Non-trivial: File renames detected: ${changedFilesStatuses.renames.map((r) => `${r.from} → ${r.to}`).join(", ")}`, + `Significant: File renames detected: ${changedFilesStatuses.renames.map((r) => `${r.from} → ${r.to}`).join(", ")}`, ); return true; } @@ -161,12 +158,13 @@ function checkForNonTrivialFileOperations(changedFilesStatuses, core) { * @param {import('./pr-changes.js').PullRequestChanges} changes - Changes object to update * @returns {Promise} */ -async function analyzePullRequestChanges(changedFiles, git, core, changes) { +async function analyzeAndUpdatePullRequestChanges(changedFiles, git, core, changes) { // Categorize files by type const documentationFiles = changedFiles.filter(markdown); const exampleFiles = changedFiles.filter(example); - const specFiles = changedFiles.filter(json).filter((file) => !example(file)); - const otherFiles = changedFiles.filter((file) => !json(file) && !markdown(file)); + const specFiles = changedFiles.filter(swagger); + const otherFiles = changedFiles.filter((file) => !markdown(file) && !example(file) && !swagger(file)); + core.info( `File breakdown: ${documentationFiles.length} docs, ${exampleFiles.length} examples, ${specFiles.length} specs, ${otherFiles.length} other`, @@ -195,8 +193,12 @@ async function analyzePullRequestChanges(changedFiles, git, core, changes) { core.info(`Analyzing file: ${file}`); try { - const isNonFunctional = await analyzeSpecFileForNonFunctionalChanges(file, git, core); - if (isNonFunctional) { + const hasOnlyNonFunctionalChanges = await hasOnlyNonFunctionalChangesInSpecFile( + file, + git, + core, + ); + if (hasOnlyNonFunctionalChanges) { core.info(`File ${file} contains only non-functional changes`); } else { hasFunctionalChanges = true; @@ -218,13 +220,13 @@ async function analyzePullRequestChanges(changedFiles, git, core, changes) { /** * Analyzes changes in a spec file to determine if they are only non-functional - * Note: New files and deletions should already be filtered by checkForNonTrivialFileOperations + * Note: New files and deletions should already be filtered by hasSignificantFileOperations * @param {string} file - The file path * @param {import('simple-git').SimpleGit} git - Git instance * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger * @returns {Promise} - True if changes are non-functional only, false if any functional changes detected */ -async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { +async function hasOnlyNonFunctionalChangesInSpecFile(file, git, core) { let baseContent, headContent; try { @@ -252,7 +254,11 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { } } - let baseJson, headJson; + /** @type {unknown} */ + let baseJson; + + /** @type {unknown} */ + let headJson; try { baseJson = /** @type {unknown} */ (JSON.parse(baseContent)); @@ -262,7 +268,7 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { return false; } - return analyzeJsonDifferences(baseJson, headJson, "", core); + return hasNonFunctionalDifferences(baseJson, headJson, "", core); } /** @@ -271,7 +277,7 @@ async function analyzeSpecFileForNonFunctionalChanges(file, git, core) { * @param {{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[]}} statuses * @returns {string[]} */ -function getFlatChangedFilesFromStatuses(statuses) { +function getFlattenedChangedFilesFromStatuses(statuses) { /** @type {Set} */ const seen = new Set(); @@ -300,7 +306,7 @@ function getFlatChangedFilesFromStatuses(statuses) { * * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger * @returns {boolean} - True if all differences are non-functional */ -function analyzeJsonDifferences(baseObj, headObj, path, core) { +function hasNonFunctionalDifferences(baseObj, headObj, path, core) { // If types differ, it's a functional change if (typeof baseObj !== typeof headObj) { core.info(`Type change at ${path || "root"}: ${typeof baseObj} -> ${typeof headObj}`); @@ -334,7 +340,7 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { // Check each element for (let i = 0; i < baseObj.length; i++) { - if (!analyzeJsonDifferences(baseObj[i], headObj[i], `${path}[${i}]`, core)) { + if (!hasNonFunctionalDifferences(baseObj[i], headObj[i], `${path}[${i}]`, core)) { return false; } } @@ -350,8 +356,14 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { headObj !== null && !Array.isArray(headObj) ) { - const baseKeys = new Set(Object.keys(baseObj)); - const headKeys = new Set(Object.keys(headObj)); + /** @type {Record} */ + const baseRecord = /** @type {Record} */ (baseObj); + + /** @type {Record} */ + const headRecord = /** @type {Record} */ (headObj); + + const baseKeys = new Set(Object.keys(baseRecord)); + const headKeys = new Set(Object.keys(headRecord)); // Check for added or removed keys for (const key of baseKeys) { @@ -380,7 +392,7 @@ function analyzeJsonDifferences(baseObj, headObj, path, core) { for (const key of baseKeys) { if (headKeys.has(key)) { const fullPath = path ? `${path}.${key}` : key; - if (!analyzeJsonDifferences(baseObj[key], headObj[key], fullPath, core)) { + if (!hasNonFunctionalDifferences(baseRecord[key], headRecord[key], fullPath, core)) { // If the change is in a non-functional property, it's still non-functional if (NON_FUNCTIONAL_PROPERTIES.has(key)) { core.info(`Property changed at ${fullPath} (non-functional)`); diff --git a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js index 7a3b8b216c3c..65597ae6a47c 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js @@ -72,10 +72,7 @@ function createSuccessResult({ headSha, issueNumber, incrementalTypeSpec, isTriv }; } -/** - * @param {Object} param0 - * @param {boolean} param0.incrementalTypeSpec - */ +/** @param {{ incrementalTypeSpec: boolean, isTrivial?: boolean }} param0 */ function createMockGithub({ incrementalTypeSpec, isTrivial = false }) { const github = createMockGithubBase(); diff --git a/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js b/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js index 1731020d9ea7..d2d16a05ef7f 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-incremental-typespec.test.js @@ -2,11 +2,15 @@ import { relative, resolve } from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { repoRoot } from "../../../shared/test/repo.js"; -/** @type {import("vitest").MockedFunction} */ -const mockRaw = vi.hoisted(() => vi.fn().mockResolvedValue("")); +const mockRaw = vi.hoisted( + /** @returns {import("vitest").MockedFunction} */ + () => vi.fn().mockResolvedValue(""), +); -/** @type {import("vitest").MockedFunction} */ -const mockShow = vi.hoisted(() => vi.fn().mockResolvedValue("")); +const mockShow = vi.hoisted( + /** @returns {import("vitest").MockedFunction} */ + () => vi.fn().mockResolvedValue(""), +); vi.mock("simple-git", () => ({ simpleGit: vi.fn().mockReturnValue({ @@ -33,7 +37,7 @@ describe("incrementalTypeSpec", () => { }); it("rejects if inputs null", async () => { - await expect(incrementalTypeSpec(undefined)).rejects.toThrow(); + await expect(incrementalTypeSpec(/** @type {any} */ (undefined))).rejects.toThrow(); }); it("returns false if no changed RM files", async () => { diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index 8483bf96e5ad..7f4578e87761 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -1,18 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +/** @type {import("vitest").Mock<(args: string[]) => Promise>} */ +const mockShow = vi.hoisted(() => vi.fn().mockResolvedValue("")); + vi.mock("simple-git", () => ({ simpleGit: vi.fn().mockReturnValue({ - show: vi.fn().mockResolvedValue(""), + show: mockShow, }), })); - -import * as simpleGit from "simple-git"; import * as changedFiles from "../../../shared/src/changed-files.js"; import { checkTrivialChanges } from "../../src/arm-auto-signoff/trivial-changes-check.js"; -import { createMockContext, createMockCore } from "../mocks.js"; +import { createMockCore } from "../mocks.js"; const core = createMockCore(); -const context = createMockContext(); /** @typedef {import("../../src/arm-auto-signoff/pr-changes.js").PullRequestChanges} PullRequestChanges */ @@ -57,9 +57,7 @@ function parsePullRequestChanges(value) { * @returns {{ show: import("vitest").Mock<(args: string[]) => Promise> }} */ function getMockGit() { - return /** @type {{ show: import("vitest").Mock<(args: string[]) => Promise> }} */ ( - /** @type {unknown} */ (simpleGit.simpleGit()) - ); + return { show: mockShow }; } describe("checkTrivialChanges", () => { From bef163e1e30dcb19c135a2ebd676189624b5633a Mon Sep 17 00:00:00 2001 From: akhilailla Date: Wed, 7 Jan 2026 15:39:35 -0600 Subject: [PATCH 27/36] Fix prettier errors --- .github/workflows/arm-auto-signoff-status.yaml | 4 ++-- .../src/arm-auto-signoff/arm-auto-signoff-code.js | 1 - .../src/arm-auto-signoff/arm-auto-signoff-status.js | 8 +++++--- .github/workflows/src/arm-auto-signoff/pr-changes.js | 12 ++---------- .../src/arm-auto-signoff/trivial-changes-check.js | 9 +++++---- .github/workflows/src/label.js | 2 +- .../arm-auto-signoff/trivial-changes-check.test.js | 6 +++--- 7 files changed, 18 insertions(+), 24 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index f7ae2fc745c0..15578381d4af 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -103,14 +103,14 @@ jobs: with: name: "ARMAutoSignedOff-IncrementalTSP" value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-IncrementalTSP'] == 'add' }}" - + - if: fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] != 'none' name: Upload artifact for ARMAutoSignedOff-Trivial-Test label uses: ./.github/actions/add-label-artifact with: name: "ARMAutoSignedOff-Trivial-Test" value: "${{ fromJson(steps.get-label-action.outputs.result).labelActions['ARMAutoSignedOff-Trivial-Test'] == 'add' }}" - + # Required for consumers to identify the head SHA associated with this workflow run. # Output can be trusted, because it was uploaded from a workflow that is trusted, # because "issue_comment", and "workflow_run" only trigger on workflows in the default branch. diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index c8806065dc94..eeb873ab990d 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -3,7 +3,6 @@ import { checkTrivialChanges } from "./trivial-changes-check.js"; /** @typedef {import("./pr-changes.js").PullRequestChanges} PullRequestChanges */ - /** * Main entry point for ARM Auto Signoff Code workflow. * diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index ae922cda43a5..6912e251a9ab 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -100,7 +100,9 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, // Check if any auto sign-off labels are currently present // Only proceed with auto sign-off logic if auto labels exist or we're about to add them - const hasAutoSignedOffLabels = labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP); + const hasAutoSignedOffLabels = labelNames.includes( + ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP, + ); core.info(`Labels: ${inspect(labelNames)}`); core.info(`Has auto signed-off labels: ${hasAutoSignedOffLabels}`); @@ -301,7 +303,7 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) run_id: run.id, per_page: PER_PAGE_MAX, }); - + /** @type {string[]} */ const artifactNames = artifacts.map((a) => a.name); core.info(`${wfName} artifactNames: ${JSON.stringify(artifactNames)}`); @@ -347,4 +349,4 @@ async function checkArmAnalysisWorkflow(workflowRuns, github, owner, repo, core) incrementalTypeSpec: incrementalTypeSpec, isTrivial: isTrivial, }; -} \ No newline at end of file +} diff --git a/.github/workflows/src/arm-auto-signoff/pr-changes.js b/.github/workflows/src/arm-auto-signoff/pr-changes.js index f1e9dc8ba87b..2d40641d71b7 100644 --- a/.github/workflows/src/arm-auto-signoff/pr-changes.js +++ b/.github/workflows/src/arm-auto-signoff/pr-changes.js @@ -52,11 +52,7 @@ export class PullRequestChanges { */ isDocumentationOnly() { return ( - this.rmDocumentation && - !this.rmExamples && - !this.rmFunctional && - !this.rmOther && - !this.other + this.rmDocumentation && !this.rmExamples && !this.rmFunctional && !this.rmOther && !this.other ); } @@ -65,11 +61,7 @@ export class PullRequestChanges { */ isExamplesOnly() { return ( - !this.rmDocumentation && - this.rmExamples && - !this.rmFunctional && - !this.rmOther && - !this.other + !this.rmDocumentation && this.rmExamples && !this.rmFunctional && !this.rmOther && !this.other ); } diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 2a63f2814c0c..ef12190669d0 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -7,7 +7,7 @@ import { getChangedFilesStatuses, markdown, resourceManager, - swagger + swagger, } from "../../../shared/src/changed-files.js"; import { CoreLogger } from "../core-logger.js"; import { PullRequestChanges } from "./pr-changes.js"; @@ -98,7 +98,7 @@ export async function checkTrivialChanges(core) { changedRmFilesStatuses, core, ); - + if (hasSignificantFileOperationsInPr) { core.info("Significant file operations detected (new spec files, deletions, or renames)"); // These are functional changes by policy @@ -163,8 +163,9 @@ async function analyzeAndUpdatePullRequestChanges(changedFiles, git, core, chang const documentationFiles = changedFiles.filter(markdown); const exampleFiles = changedFiles.filter(example); const specFiles = changedFiles.filter(swagger); - const otherFiles = changedFiles.filter((file) => !markdown(file) && !example(file) && !swagger(file)); - + const otherFiles = changedFiles.filter( + (file) => !markdown(file) && !example(file) && !swagger(file), + ); core.info( `File breakdown: ${documentationFiles.length} docs, ${exampleFiles.length} examples, ${specFiles.length} specs, ${otherFiles.length} other`, diff --git a/.github/workflows/src/label.js b/.github/workflows/src/label.js index 2abaa1cd21de..6e9b1b4e06e1 100644 --- a/.github/workflows/src/label.js +++ b/.github/workflows/src/label.js @@ -18,4 +18,4 @@ export const ArmAutoSignoffLabel = Object.freeze({ ArmAutoSignedOff: "ARMAutoSignedOff", ArmAutoSignedOffIncrementalTSP: "ARMAutoSignedOff-IncrementalTSP", ArmAutoSignedOffTrivialTest: "ARMAutoSignedOff-Trivial-Test", -}); \ No newline at end of file +}); diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index 7f4578e87761..67df65aed562 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import * as changedFiles from "../../../shared/src/changed-files.js"; +import { checkTrivialChanges } from "../../src/arm-auto-signoff/trivial-changes-check.js"; +import { createMockCore } from "../mocks.js"; /** @type {import("vitest").Mock<(args: string[]) => Promise>} */ const mockShow = vi.hoisted(() => vi.fn().mockResolvedValue("")); @@ -8,9 +11,6 @@ vi.mock("simple-git", () => ({ show: mockShow, }), })); -import * as changedFiles from "../../../shared/src/changed-files.js"; -import { checkTrivialChanges } from "../../src/arm-auto-signoff/trivial-changes-check.js"; -import { createMockCore } from "../mocks.js"; const core = createMockCore(); From 57cca4b7d0910d752a5be99f07c1b336031dbc6d Mon Sep 17 00:00:00 2001 From: akhilailla Date: Fri, 9 Jan 2026 18:20:30 -0600 Subject: [PATCH 28/36] Fix prettier errors, Refactor trivial changes, minor updates --- .../workflows/arm-auto-signoff-status.yaml | 2 +- .../arm-auto-signoff/arm-auto-signoff-code.js | 2 +- .../arm-auto-signoff-status.js | 9 ++- .../arm-auto-signoff/trivial-changes-check.js | 65 +++++++++-------- .../trivial-changes-check.test.js | 69 +++++-------------- 5 files changed, 59 insertions(+), 88 deletions(-) diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 15578381d4af..9338240a8445 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -84,7 +84,7 @@ jobs: uses: actions/github-script@v8 with: script: | - const { default: getLabelAction } = + const { getLabelAction } = await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js'); return await getLabelAction({ github, context, core }); diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index eeb873ab990d..598c28a6b487 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -12,7 +12,7 @@ import { checkTrivialChanges } from "./trivial-changes-check.js"; * - Trivial changes check * * @param {import("@actions/github-script").AsyncFunctionArguments} args - * @returns {Promise<{ incremental: boolean, trivial: boolean }>} JSON object consumed by downstream workflow logic. + * @returns {Promise<{ incremental: boolean, trivial: boolean }>} */ export async function armAutoSignoffCode(args) { const { core } = args; diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 6912e251a9ab..7cc0f8cae52d 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -54,7 +54,7 @@ function readBooleanArtifactValue(artifactNames, key) { * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments * @returns {Promise<{headSha: string, issueNumber: number, labelActions: ManagedLabelActions}>} */ -export default async function getLabelAction({ github, context, core }) { +export async function getLabelAction({ github, context, core }) { const { owner, repo, issue_number, head_sha } = await extractInputs(github, context, core); return await getLabelActionImpl({ @@ -217,7 +217,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, const autoIncrementalTSP = armAnalysisResult.incrementalTypeSpec; const autoTrivialTest = armAnalysisResult.isTrivial; - // Add ARMAutoSignOff label only when the PR is identified as an incremental typespec + // Add ARMSignOff label only when the PR is identified as an incremental typespec // As the trivial changes sign-off is being released in test mode const armSignOffAction = autoIncrementalTSP ? LabelAction.Add @@ -240,10 +240,9 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, }; } - // If any statuses are missing or pending, return empty labels only if we had auto labels before - // This prevents removing manually-added ARMSignedOff labels + // If any statuses are missing or pending, no-op to prevent frequent remove/add label as checks re-run core.info("One or more statuses are still pending"); - return removeAutoSignedOffLabelsIfPresent(); + return noneResult; } /** diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index ef12190669d0..4744027b19e3 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -45,9 +45,7 @@ export async function checkTrivialChanges(core) { const changes = new PullRequestChanges(); // Compare the pull request merge commit (HEAD) against its first parent (HEAD^). - core.info("Comparing against base commit: HEAD^"); - - const options = { + const options = { cwd: process.env.GITHUB_WORKSPACE, logger: new CoreLogger(core), }; @@ -155,7 +153,7 @@ function hasSignificantFileOperations(changedFilesStatuses, core) { * @param {string[]} changedFiles - Array of changed file paths * @param {import('simple-git').SimpleGit} git - Git instance * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger - * @param {import('./pr-changes.js').PullRequestChanges} changes - Changes object to update + * @param {PullRequestChangesType} changes - Changes object to update * @returns {Promise} */ async function analyzeAndUpdatePullRequestChanges(changedFiles, git, core, changes) { @@ -199,14 +197,16 @@ async function analyzeAndUpdatePullRequestChanges(changedFiles, git, core, chang git, core, ); + if (hasOnlyNonFunctionalChanges) { core.info(`File ${file} contains only non-functional changes`); - } else { - hasFunctionalChanges = true; - core.info(`File ${file} contains functional changes`); - // Once we find functional changes, we can stop - break; + continue; } + + hasFunctionalChanges = true; + core.info(`File ${file} contains functional changes`); + // Once we find functional changes, we can stop + break; } catch (error) { core.warning(`Failed to analyze ${file}: ${inspect(error)}`); // On error, treat as functional to be conservative @@ -269,7 +269,7 @@ async function hasOnlyNonFunctionalChangesInSpecFile(file, git, core) { return false; } - return hasNonFunctionalDifferences(baseJson, headJson, "", core); + return !hasFunctionalDifferences(baseJson, headJson, "", core); } /** @@ -305,22 +305,22 @@ function getFlattenedChangedFilesFromStatuses(statuses) { * @param {unknown} headObj - Head object * @param {string} path - Current path in the object * * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger - * @returns {boolean} - True if all differences are non-functional + * @returns {boolean} - True if any functional differences are detected */ -function hasNonFunctionalDifferences(baseObj, headObj, path, core) { +function hasFunctionalDifferences(baseObj, headObj, path, core) { // If types differ, it's a functional change if (typeof baseObj !== typeof headObj) { core.info(`Type change at ${path || "root"}: ${typeof baseObj} -> ${typeof headObj}`); - return false; + return true; } // Handle null values if (baseObj === null || headObj === null) { if (baseObj !== headObj) { core.info(`Null value change at ${path || "root"}`); - return false; + return true; } - return true; + return false; } // Handle arrays @@ -334,18 +334,18 @@ function hasNonFunctionalDifferences(baseObj, headObj, path, core) { // Special case: if arrays contain only strings and changes are in non-functional properties like tags if (isNonFunctionalArrayChange(baseObj, headObj, path)) { - return true; + return false; } - return false; + return true; } // Check each element for (let i = 0; i < baseObj.length; i++) { - if (!hasNonFunctionalDifferences(baseObj[i], headObj[i], `${path}[${i}]`, core)) { - return false; + if (hasFunctionalDifferences(baseObj[i], headObj[i], `${path}[${i}]`, core)) { + return true; } } - return true; + return false; } // Handle objects @@ -372,7 +372,7 @@ function hasNonFunctionalDifferences(baseObj, headObj, path, core) { const fullPath = path ? `${path}.${key}` : key; if (!NON_FUNCTIONAL_PROPERTIES.has(key)) { core.info(`Property removed at ${fullPath} (functional)`); - return false; + return true; } core.info(`Property removed at ${fullPath} (non-functional)`); } @@ -383,7 +383,7 @@ function hasNonFunctionalDifferences(baseObj, headObj, path, core) { const fullPath = path ? `${path}.${key}` : key; if (!NON_FUNCTIONAL_PROPERTIES.has(key)) { core.info(`Property added at ${fullPath} (functional)`); - return false; + return true; } core.info(`Property added at ${fullPath} (non-functional)`); } @@ -393,18 +393,25 @@ function hasNonFunctionalDifferences(baseObj, headObj, path, core) { for (const key of baseKeys) { if (headKeys.has(key)) { const fullPath = path ? `${path}.${key}` : key; - if (!hasNonFunctionalDifferences(baseRecord[key], headRecord[key], fullPath, core)) { - // If the change is in a non-functional property, it's still non-functional + const childHasFunctional = hasFunctionalDifferences( + baseRecord[key], + headRecord[key], + fullPath, + core, + ); + + if (childHasFunctional) { + // If the change is in a non-functional property, treat as non-functional. if (NON_FUNCTIONAL_PROPERTIES.has(key)) { core.info(`Property changed at ${fullPath} (non-functional)`); continue; } - return false; + return true; } } } - return true; + return false; } // Handle primitive values @@ -414,15 +421,15 @@ function hasNonFunctionalDifferences(baseObj, headObj, path, core) { core.info( `Value changed at ${path || "root"} (non-functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, ); - return true; + return false; } core.info( `Value changed at ${path || "root"} (functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, ); - return false; + return true; } - return true; + return false; } /** diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index 67df65aed562..38a6065d92c3 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -16,41 +16,6 @@ const core = createMockCore(); /** @typedef {import("../../src/arm-auto-signoff/pr-changes.js").PullRequestChanges} PullRequestChanges */ -/** - * @param {unknown} value - * @returns {value is PullRequestChanges} - */ -function isPullRequestChanges(value) { - if (typeof value !== "object" || value === null) { - return false; - } - /** @type {Record} */ - const obj = /** @type {Record} */ (value); - return ( - typeof obj.rmDocumentation === "boolean" && - typeof obj.rmExamples === "boolean" && - typeof obj.rmFunctional === "boolean" && - typeof obj.rmOther === "boolean" && - typeof obj.other === "boolean" - ); -} - -/** - * Accept either the JSON string form (workflow output) or the object instance returned - * by the checker (which implements `toJSON`). - * - * @param {unknown} value - * @returns {PullRequestChanges} - */ -function parsePullRequestChanges(value) { - const jsonText = typeof value === "string" ? value : JSON.stringify(value); - const parsed = /** @type {unknown} */ (JSON.parse(jsonText)); - if (!isPullRequestChanges(parsed)) { - throw new Error("Unexpected PullRequestChanges JSON shape"); - } - return parsed; -} - /** * The tests mock `simple-git` to return a minimal object with only `show`. * Return that mock with a precise type so ESLint doesn't treat it as an unbound method. @@ -79,7 +44,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -107,7 +72,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -134,7 +99,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -159,7 +124,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -184,7 +149,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -210,7 +175,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -236,7 +201,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: true, @@ -262,7 +227,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -288,7 +253,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: true, @@ -338,7 +303,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -389,7 +354,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -440,7 +405,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); // Should detect functional changes, so overall not trivial expect(parsed).toEqual({ @@ -477,7 +442,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); // Should treat JSON parsing errors as non-trivial changes expect(parsed).toEqual({ @@ -505,7 +470,7 @@ describe("checkTrivialChanges", () => { getMockGit().show.mockRejectedValue(new Error("Git operation failed")); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); // Should treat git errors as non-trivial changes expect(parsed).toEqual({ @@ -572,7 +537,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -633,7 +598,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: false, @@ -660,7 +625,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = parsePullRequestChanges(result); + const parsed = result.toJSON(); expect(parsed).toEqual({ rmDocumentation: true, From c9a29075056499b6b802a46771faf8c4634b03d4 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Tue, 13 Jan 2026 15:30:58 -0600 Subject: [PATCH 29/36] Fix prettier failures --- .github/workflows/src/arm-auto-signoff/trivial-changes-check.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index 4744027b19e3..d684fa41a55e 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -45,7 +45,7 @@ export async function checkTrivialChanges(core) { const changes = new PullRequestChanges(); // Compare the pull request merge commit (HEAD) against its first parent (HEAD^). - const options = { + const options = { cwd: process.env.GITHUB_WORKSPACE, logger: new CoreLogger(core), }; From d8d3b38ebd496c357da314114f562973d23b17bd Mon Sep 17 00:00:00 2001 From: akhilailla Date: Wed, 14 Jan 2026 14:56:47 -0600 Subject: [PATCH 30/36] Update log statements, Fix auto sign off label list --- .../arm-auto-signoff/arm-auto-signoff-code.js | 1 - .../arm-auto-signoff-status.js | 7 ++++--- .../arm-auto-signoff/trivial-changes-check.js | 2 +- .../arm-auto-signoff-status.test.js | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 598c28a6b487..21ee11ea757b 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -28,7 +28,6 @@ export async function armAutoSignoffCode(args) { // Run trivial changes check core.startGroup("Checking for trivial changes"); const trivialChangesResult = await checkTrivialChanges(core); - core.info(`Trivial changes result: ${JSON.stringify(trivialChangesResult)}`); core.endGroup(); const isTrivial = trivialChangesResult.isTrivial(); diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 7cc0f8cae52d..17d7de0e2512 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -100,9 +100,10 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, // Check if any auto sign-off labels are currently present // Only proceed with auto sign-off logic if auto labels exist or we're about to add them - const hasAutoSignedOffLabels = labelNames.includes( - ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP, - ); + const hasAutoSignedOffLabels = + labelNames.includes(ArmAutoSignoffLabel.ArmSignedOff) || + labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP) || + labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOffTrivialTest); core.info(`Labels: ${inspect(labelNames)}`); core.info(`Has auto signed-off labels: ${hasAutoSignedOffLabels}`); diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index d684fa41a55e..a763c8fc1791 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -70,7 +70,7 @@ export async function checkTrivialChanges(core) { if (hasNonRmChanges) { const nonRmFiles = changedFiles.filter((file) => !resourceManager(file)); core.info( - `PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff`, + `PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff. Also, no further validation to resournce manger files is oe`, ); core.info( `Non-RM files: ${nonRmFiles.slice(0, 5).join(", ")}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ""}`, diff --git a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js index 65597ae6a47c..1aca3ca35242 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js @@ -175,6 +175,24 @@ describe("getLabelActionImpl", () => { ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); }); + it("removes ARMAutoSignedOff-Trivial-Test if analysis no longer trivial", async () => { + const github = createMockGithub({ incrementalTypeSpec: false, isTrivial: false }); + github.rest.issues.listLabelsOnIssue.mockResolvedValue({ + data: [{ name: managedLabels.autoSignedOffTrivialTest }], + }); + + await expect( + getLabelActionImpl({ + owner: "TestOwner", + repo: "TestRepo", + issue_number: 123, + head_sha: "abc123", + github: github, + core: core, + }), + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); + }); + it("removes label if analysis artifacts missing (fail closed)", async () => { const github = createMockGithub({ incrementalTypeSpec: true }); github.rest.issues.listLabelsOnIssue.mockResolvedValue({ From 539cc10811259c5fae7f55b185289eb9318b618c Mon Sep 17 00:00:00 2001 From: akhilailla Date: Tue, 20 Jan 2026 13:28:04 -0600 Subject: [PATCH 31/36] Update trivial changes logic to evaluate functional changes --- .../arm-auto-signoff/trivial-changes-check.js | 110 ++++++++---------- 1 file changed, 50 insertions(+), 60 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js index a763c8fc1791..7c8214fd8dad 100644 --- a/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js +++ b/.github/workflows/src/arm-auto-signoff/trivial-changes-check.js @@ -70,7 +70,7 @@ export async function checkTrivialChanges(core) { if (hasNonRmChanges) { const nonRmFiles = changedFiles.filter((file) => !resourceManager(file)); core.info( - `PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff. Also, no further validation to resournce manger files is oe`, + `PR contains ${nonRmFiles.length} non-resource-manager changes - not eligible for ARM auto-signoff. Also, no further validation to resource manager files is performed.`, ); core.info( `Non-RM files: ${nonRmFiles.slice(0, 5).join(", ")}${nonRmFiles.length > 5 ? ` ... and ${nonRmFiles.length - 5} more` : ""}`, @@ -186,48 +186,38 @@ async function analyzeAndUpdatePullRequestChanges(changedFiles, git, core, chang if (specFiles.length > 0) { core.info(`Analyzing ${specFiles.length} spec files...`); - let hasFunctionalChanges = false; - for (const file of specFiles) { core.info(`Analyzing file: ${file}`); try { - const hasOnlyNonFunctionalChanges = await hasOnlyNonFunctionalChangesInSpecFile( - file, - git, - core, - ); + const hasFunctionalChanges = await hasFunctionalChangesInSpecFile(file, git, core); - if (hasOnlyNonFunctionalChanges) { - core.info(`File ${file} contains only non-functional changes`); - continue; + if (hasFunctionalChanges) { + changes.rmFunctional = true; + core.info(`File ${file} contains functional changes`); + return; } - hasFunctionalChanges = true; - core.info(`File ${file} contains functional changes`); - // Once we find functional changes, we can stop - break; + core.info(`File ${file} contains only non-functional changes`); } catch (error) { core.warning(`Failed to analyze ${file}: ${inspect(error)}`); // On error, treat as functional to be conservative - hasFunctionalChanges = true; - break; + changes.rmFunctional = true; + return; } } - - changes.rmFunctional = hasFunctionalChanges; } } /** - * Analyzes changes in a spec file to determine if they are only non-functional - * Note: New files and deletions should already be filtered by hasSignificantFileOperations - * @param {string} file - The file path + * Analyzes changes in a spec file to determine if they are functional. + * Note: New files and deletions should already be filtered by hasSignificantFileOperations. + * @param {string} file * @param {import('simple-git').SimpleGit} git - Git instance * @param {import('@actions/github-script').AsyncFunctionArguments['core']} core - Core logger - * @returns {Promise} - True if changes are non-functional only, false if any functional changes detected + * @returns {Promise} True if changes are functional, false if only non-functional changes are detected */ -async function hasOnlyNonFunctionalChangesInSpecFile(file, git, core) { +async function hasFunctionalChangesInSpecFile(file, git, core) { let baseContent, headContent; try { @@ -237,10 +227,9 @@ async function hasOnlyNonFunctionalChangesInSpecFile(file, git, core) { if (e instanceof Error && e.message.includes("does not exist")) { // New file - should have been caught earlier, but treat as functional to be safe core.info(`File ${file} is new - treating as functional`); - return false; - } else { - throw e; + return true; } + throw e; } try { @@ -249,10 +238,9 @@ async function hasOnlyNonFunctionalChangesInSpecFile(file, git, core) { if (e instanceof Error && e.message.includes("does not exist")) { // File deleted - should have been caught earlier, but treat as functional to be safe core.info(`File ${file} was deleted - treating as functional`); - return false; - } else { - throw e; + return true; } + throw e; } /** @type {unknown} */ @@ -266,10 +254,11 @@ async function hasOnlyNonFunctionalChangesInSpecFile(file, git, core) { headJson = /** @type {unknown} */ (JSON.parse(headContent)); } catch (error) { core.warning(`Failed to parse JSON for ${file}: ${inspect(error)}`); - return false; + // Be conservative: if we can't parse, assume functional changes. + return true; } - return !hasFunctionalDifferences(baseJson, headJson, "", core); + return hasFunctionalDifferences(baseJson, headJson, "", core); } /** @@ -333,10 +322,7 @@ function hasFunctionalDifferences(baseObj, headObj, path, core) { core.info(`Array length change at ${arrayPath}: ${baseObj.length} -> ${headObj.length}`); // Special case: if arrays contain only strings and changes are in non-functional properties like tags - if (isNonFunctionalArrayChange(baseObj, headObj, path)) { - return false; - } - return true; + return isFunctionalPath(path); } // Check each element @@ -370,7 +356,7 @@ function hasFunctionalDifferences(baseObj, headObj, path, core) { for (const key of baseKeys) { if (!headKeys.has(key)) { const fullPath = path ? `${path}.${key}` : key; - if (!NON_FUNCTIONAL_PROPERTIES.has(key)) { + if (isFunctionalPath(fullPath)) { core.info(`Property removed at ${fullPath} (functional)`); return true; } @@ -381,7 +367,7 @@ function hasFunctionalDifferences(baseObj, headObj, path, core) { for (const key of headKeys) { if (!baseKeys.has(key)) { const fullPath = path ? `${path}.${key}` : key; - if (!NON_FUNCTIONAL_PROPERTIES.has(key)) { + if (isFunctionalPath(fullPath)) { core.info(`Property added at ${fullPath} (functional)`); return true; } @@ -393,20 +379,20 @@ function hasFunctionalDifferences(baseObj, headObj, path, core) { for (const key of baseKeys) { if (headKeys.has(key)) { const fullPath = path ? `${path}.${key}` : key; - const childHasFunctional = hasFunctionalDifferences( + const childHasFunctionalDifferences = hasFunctionalDifferences( baseRecord[key], headRecord[key], fullPath, core, ); - if (childHasFunctional) { - // If the change is in a non-functional property, treat as non-functional. - if (NON_FUNCTIONAL_PROPERTIES.has(key)) { - core.info(`Property changed at ${fullPath} (non-functional)`); - continue; + if (childHasFunctionalDifferences) { + // If the change is under a non-functional path, treat as non-functional. + if (isFunctionalPath(fullPath)) { + return true; } - return true; + core.info(`Property changed at ${fullPath} (non-functional)`); + continue; } } } @@ -416,30 +402,34 @@ function hasFunctionalDifferences(baseObj, headObj, path, core) { // Handle primitive values if (baseObj !== headObj) { - const currentProperty = path.split(".").pop() || path.split("[")[0]; - if (NON_FUNCTIONAL_PROPERTIES.has(currentProperty)) { + if (isFunctionalPath(path)) { core.info( - `Value changed at ${path || "root"} (non-functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, + `Value changed at ${path || "root"} (functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, ); - return false; + return true; } core.info( - `Value changed at ${path || "root"} (functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, + `Value changed at ${path || "root"} (non-functional): ${inspect(baseObj)} -> ${inspect(headObj)}`, ); - return true; + return false; } - return false; } /** - * Checks if an array change is non-functional (e.g., tags, examples) - * @param {any[]} baseArray - Base array - * @param {any[]} headArray - Head array - * @param {string} path - Current path - * @returns {boolean} - True if non-functional + * Returns true if the current path is under a functional property. + * Handles array indices (e.g. `tags[0]` -> `tags`). + * @param {string} path + * @returns {boolean} */ -function isNonFunctionalArrayChange(baseArray, headArray, path) { - const currentProperty = path.split(".").pop() || path; - return NON_FUNCTIONAL_PROPERTIES.has(currentProperty); +function isFunctionalPath(path) { + if (typeof path !== "string" || path.length === 0) return true; + + for (const segment of path.split(".")) { + if (segment.length === 0) continue; + const key = segment.split("[")[0]; + if (NON_FUNCTIONAL_PROPERTIES.has(key)) return false; + } + + return true; } From c5e62f8bd533914a871ae70bad3800252da59d20 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Tue, 20 Jan 2026 15:14:03 -0600 Subject: [PATCH 32/36] Remove updated timeouts in test --- .github/shared/test/exec.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/shared/test/exec.test.js b/.github/shared/test/exec.test.js index 7b4789732cc8..87cc88635450 100644 --- a/.github/shared/test/exec.test.js +++ b/.github/shared/test/exec.test.js @@ -40,7 +40,7 @@ describe("execNpm", () => { stdout: /** @type {unknown} */ (expect.toSatisfy((v) => semver.valid(String(v)) !== null)), stderr: "", }); - }, 120000); + }); it("fails with --help", async () => { await expect(execNpm(["--help"], options)).rejects.toMatchObject({ @@ -48,7 +48,7 @@ describe("execNpm", () => { stderr: "", code: 1, }); - }, 120000); + }); }); describe("execNpmExec", () => { @@ -61,7 +61,7 @@ describe("execNpmExec", () => { stderr: "", error: undefined, }); - }, 120000); + }); }); describe("isExecError", () => { From 41e4df1887870a0109e142e678ce2a1327533a72 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 22 Jan 2026 10:48:52 -0600 Subject: [PATCH 33/36] Updates made to address feedback --- .github/workflows/arm-auto-signoff-code.yaml | 2 +- .../workflows/arm-auto-signoff-status.yaml | 2 +- .../arm-auto-signoff/arm-auto-signoff-code.js | 4 +- .../arm-auto-signoff-labels.js | 11 + .../arm-auto-signoff-status.js | 7 +- .../src/arm-auto-signoff/pr-changes.js | 28 -- .github/workflows/src/label.js | 12 - .../arm-auto-signoff-status.test.js | 68 +++++ .../trivial-changes-check.test.js | 287 ++++++++++++++---- 9 files changed, 321 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/src/arm-auto-signoff/arm-auto-signoff-labels.js diff --git a/.github/workflows/arm-auto-signoff-code.yaml b/.github/workflows/arm-auto-signoff-code.yaml index fc17cc5c6241..06dc456812bf 100644 --- a/.github/workflows/arm-auto-signoff-code.yaml +++ b/.github/workflows/arm-auto-signoff-code.yaml @@ -45,7 +45,7 @@ jobs: with: result-encoding: json script: | - const { armAutoSignoffCode } = + const { default: armAutoSignoffCode } = await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js'); return await armAutoSignoffCode({ github, context, core }); diff --git a/.github/workflows/arm-auto-signoff-status.yaml b/.github/workflows/arm-auto-signoff-status.yaml index 9338240a8445..15578381d4af 100644 --- a/.github/workflows/arm-auto-signoff-status.yaml +++ b/.github/workflows/arm-auto-signoff-status.yaml @@ -84,7 +84,7 @@ jobs: uses: actions/github-script@v8 with: script: | - const { getLabelAction } = + const { default: getLabelAction } = await import('${{ github.workspace }}/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js'); return await getLabelAction({ github, context, core }); diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 21ee11ea757b..9dc32b347210 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -14,8 +14,8 @@ import { checkTrivialChanges } from "./trivial-changes-check.js"; * @param {import("@actions/github-script").AsyncFunctionArguments} args * @returns {Promise<{ incremental: boolean, trivial: boolean }>} */ -export async function armAutoSignoffCode(args) { - const { core } = args; +export default async function armAutoSignoffCode({core}) { + core.info("Starting ARM Auto Signoff Code analysis."); diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-labels.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-labels.js new file mode 100644 index 000000000000..c0e7ba1f66cc --- /dev/null +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-labels.js @@ -0,0 +1,11 @@ +/** + * ARM auto-signoff label names. + * @readonly + * @enum {string} + */ +export const ArmAutoSignoffLabel = Object.freeze({ + ArmSignedOff: "ARMSignedOff", + ArmAutoSignedOff: "ARMAutoSignedOff", + ArmAutoSignedOffIncrementalTSP: "ARMAutoSignedOff-IncrementalTSP", + ArmAutoSignedOffTrivialTest: "ARMAutoSignedOff-Trivial-Test", +}); diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 17d7de0e2512..7eec38724b68 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -3,7 +3,8 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../../shared/src/github.js"; import { equals } from "../../../shared/src/set.js"; import { byDate, invert } from "../../../shared/src/sort.js"; import { extractInputs } from "../context.js"; -import { ArmAutoSignoffLabel, LabelAction } from "../label.js"; +import { ArmAutoSignoffLabel } from "./arm-auto-signoff-labels.js"; +import { LabelAction } from "../label.js"; /** @typedef {import('@octokit/plugin-rest-endpoint-methods').RestEndpointMethodTypes} RestEndpointMethodTypes */ /** @typedef {RestEndpointMethodTypes["issues"]["listLabelsOnIssue"]["response"]["data"][number]} IssueLabel */ @@ -54,7 +55,7 @@ function readBooleanArtifactValue(artifactNames, key) { * @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments * @returns {Promise<{headSha: string, issueNumber: number, labelActions: ManagedLabelActions}>} */ -export async function getLabelAction({ github, context, core }) { +export default async function getLabelAction({ github, context, core }) { const { owner, repo, issue_number, head_sha } = await extractInputs(github, context, core); return await getLabelActionImpl({ @@ -101,7 +102,7 @@ export async function getLabelActionImpl({ owner, repo, issue_number, head_sha, // Check if any auto sign-off labels are currently present // Only proceed with auto sign-off logic if auto labels exist or we're about to add them const hasAutoSignedOffLabels = - labelNames.includes(ArmAutoSignoffLabel.ArmSignedOff) || + labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOff) || labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOffIncrementalTSP) || labelNames.includes(ArmAutoSignoffLabel.ArmAutoSignedOffTrivialTest); core.info(`Labels: ${inspect(labelNames)}`); diff --git a/.github/workflows/src/arm-auto-signoff/pr-changes.js b/.github/workflows/src/arm-auto-signoff/pr-changes.js index 2d40641d71b7..b3c5784ac06c 100644 --- a/.github/workflows/src/arm-auto-signoff/pr-changes.js +++ b/.github/workflows/src/arm-auto-signoff/pr-changes.js @@ -1,17 +1,3 @@ -/** - * JSON representation of the change flags. - * - * All properties are boolean flags that indicate presence of a change type. - * An empty PR would have all properties set to false. - * - * @typedef {Object} PullRequestChangesJSON - * @property {boolean} rmDocumentation - True if PR contains resource-manager scoped documentation (.md) changes - * @property {boolean} rmExamples - True if PR contains resource-manager scoped example changes (/examples/*.json) - * @property {boolean} rmFunctional - True if PR contains resource-manager scoped functional spec changes (API-impacting) - * @property {boolean} rmOther - True if PR contains other resource-manager scoped changes (non-trivial) - * @property {boolean} other - True if PR contains changes outside resource-manager (blocks ARM auto-signoff) - */ - /** * Represents the types of changes present in a pull request. */ @@ -64,18 +50,4 @@ export class PullRequestChanges { !this.rmDocumentation && this.rmExamples && !this.rmFunctional && !this.rmOther && !this.other ); } - - /** - * Ensure stable JSON output even though this is a class. - * @returns {PullRequestChangesJSON} - */ - toJSON() { - return { - rmDocumentation: this.rmDocumentation, - rmExamples: this.rmExamples, - rmFunctional: this.rmFunctional, - rmOther: this.rmOther, - other: this.other, - }; - } } diff --git a/.github/workflows/src/label.js b/.github/workflows/src/label.js index 6e9b1b4e06e1..c2dd54dfa3d3 100644 --- a/.github/workflows/src/label.js +++ b/.github/workflows/src/label.js @@ -7,15 +7,3 @@ export const LabelAction = Object.freeze({ Add: "add", Remove: "remove", }); - -/** - * ARM auto-signoff label names. - * @readonly - * @enum {string} - */ -export const ArmAutoSignoffLabel = Object.freeze({ - ArmSignedOff: "ARMSignedOff", - ArmAutoSignedOff: "ARMAutoSignedOff", - ArmAutoSignedOffIncrementalTSP: "ARMAutoSignedOff-IncrementalTSP", - ArmAutoSignedOffTrivialTest: "ARMAutoSignedOff-Trivial-Test", -}); diff --git a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js index 1aca3ca35242..659fdee5aac2 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js @@ -495,4 +495,72 @@ describe("getLabelActionImpl", () => { }), ); }); + + it("adds trivial test label when PR qualifies only by trivial changes", async () => { + const github = createMockGithub({ incrementalTypeSpec: false, isTrivial: true }); + + github.rest.issues.listLabelsOnIssue.mockResolvedValue({ + data: [{ name: "ARMReview" }], + }); + + github.rest.repos.listCommitStatusesForRef.mockResolvedValue({ + data: [ + { + context: "Swagger LintDiff", + state: CommitStatusState.SUCCESS, + }, + { + context: "Swagger Avocado", + state: CommitStatusState.SUCCESS, + }, + ], + }); + + await expect( + getLabelActionImpl({ + owner: "TestOwner", + repo: "TestRepo", + issue_number: 123, + head_sha: "abc123", + github: github, + core: core, + }), + ).resolves.toEqual( + createSuccessResult({ + headSha: "abc123", + issueNumber: 123, + incrementalTypeSpec: false, + isTrivial: true, + }), + ); + }); + + it("removes label if analysis artifacts have invalid boolean value (fail closed)", async () => { + const github = createMockGithub({ incrementalTypeSpec: true }); + + github.rest.issues.listLabelsOnIssue.mockResolvedValue({ + data: [{ name: "ARMAutoSignedOff-IncrementalTSP" }], + }); + + // Invalid boolean value should be treated as missing/invalid. + github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ + data: { + artifacts: [ + { name: "incremental-typespec=maybe" }, + { name: "trivial-changes=false" }, + ], + }, + }); + + await expect( + getLabelActionImpl({ + owner: "TestOwner", + repo: "TestRepo", + issue_number: 123, + head_sha: "abc123", + github: github, + core: core, + }), + ).resolves.toEqual(createRemoveManagedLabelsResult("abc123", 123)); + }); }); diff --git a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js index 38a6065d92c3..e9f81583d556 100644 --- a/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js +++ b/.github/workflows/test/arm-auto-signoff/trivial-changes-check.test.js @@ -30,10 +30,6 @@ describe("checkTrivialChanges", () => { vi.clearAllMocks(); }); - it("throws error when core and context are not provided", async () => { - await expect(checkTrivialChanges(/** @type {any} */ ({}))).rejects.toThrow(); - }); - it("returns empty change flags when no files are changed", async () => { vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ additions: [], @@ -44,9 +40,8 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: false, @@ -72,9 +67,8 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: false, @@ -99,9 +93,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: false, @@ -124,9 +116,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, // New spec files are functional @@ -149,9 +139,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, // Deleted spec files are functional @@ -175,9 +163,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, // Renamed spec files are functional @@ -201,9 +187,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: true, rmExamples: false, rmFunctional: false, @@ -227,9 +211,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: true, rmFunctional: false, @@ -253,9 +235,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: true, rmExamples: false, rmFunctional: false, @@ -303,9 +283,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: false, @@ -354,9 +332,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, @@ -405,10 +381,8 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - // Should detect functional changes, so overall not trivial - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: true, rmExamples: true, rmFunctional: true, @@ -442,10 +416,8 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - // Should treat JSON parsing errors as non-trivial changes - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, @@ -470,10 +442,8 @@ describe("checkTrivialChanges", () => { getMockGit().show.mockRejectedValue(new Error("Git operation failed")); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - // Should treat git errors as non-trivial changes - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, @@ -537,9 +507,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: false, @@ -598,9 +566,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: false, rmExamples: false, rmFunctional: true, @@ -625,9 +591,7 @@ describe("checkTrivialChanges", () => { }); const result = await checkTrivialChanges(core); - const parsed = result.toJSON(); - - expect(parsed).toEqual({ + expect(result).toMatchObject({ rmDocumentation: true, rmExamples: true, rmFunctional: false, @@ -635,4 +599,221 @@ describe("checkTrivialChanges", () => { other: false, }); }); + + it("ignores empty/invalid changed file entries", async () => { + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [""], + modifications: /** @type {any} */ ([null]), + deletions: /** @type {any} */ ([undefined]), + renames: [], + total: 0, + }); + + const result = await checkTrivialChanges(core); + + expect(result).toMatchObject({ + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, + other: false, + }); + }); + + it("treats missing base content for a modified spec file as functional", async () => { + const jsonFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", + ]; + + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.reject(new Error("does not exist")); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(JSON.stringify({ openapi: "3.0.0", info: { title: "t" } })); + } + return Promise.reject(new Error("unexpected ref")); + }); + + const result = await checkTrivialChanges(core); + expect(result).toMatchObject({ + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, + other: false, + }); + }); + + it("treats missing head content for a modified spec file as functional", async () => { + const jsonFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", + ]; + + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(JSON.stringify({ openapi: "3.0.0", info: { title: "t" } })); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.reject(new Error("does not exist")); + } + return Promise.reject(new Error("unexpected ref")); + }); + + const result = await checkTrivialChanges(core); + expect(result).toMatchObject({ + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, + other: false, + }); + }); + + it("treats array length changes under non-functional properties (tags) as non-functional", async () => { + const jsonFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", + ]; + + const oldJson = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Service API", version: "1.0" }, + tags: ["a"], + }); + + const newJson = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Service API", version: "1.0" }, + tags: ["a", "b"], + }); + + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges(core); + expect(result).toMatchObject({ + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, + other: false, + }); + }); + + it("treats type changes under non-functional properties (description) as non-functional", async () => { + const jsonFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", + ]; + + const oldJson = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Service API", version: "1.0" }, + description: "old", + }); + + const newJson = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Service API", version: "1.0" }, + description: 123, + }); + + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges(core); + expect(result).toMatchObject({ + rmDocumentation: false, + rmExamples: false, + rmFunctional: false, + rmOther: false, + other: false, + }); + }); + + it("treats a root array length change as functional (conservative)", async () => { + const jsonFiles = [ + "specification/someservice/resource-manager/Microsoft.Service/stable/2021-01-01/service.json", + ]; + + const oldJson = JSON.stringify([]); + const newJson = JSON.stringify([1]); + + vi.spyOn(changedFiles, "getChangedFilesStatuses").mockResolvedValue({ + additions: [], + modifications: jsonFiles, + deletions: [], + renames: [], + total: 0, + }); + + getMockGit().show.mockImplementation((args) => { + const ref = args[0]; + if (typeof ref === "string" && ref.startsWith("HEAD^:")) { + return Promise.resolve(oldJson); + } + if (typeof ref === "string" && ref.startsWith("HEAD:")) { + return Promise.resolve(newJson); + } + return Promise.reject(new Error("does not exist")); + }); + + const result = await checkTrivialChanges(core); + expect(result).toMatchObject({ + rmDocumentation: false, + rmExamples: false, + rmFunctional: true, + rmOther: false, + other: false, + }); + }); }); From e349a3696839fa3b1b8c926729295d5878bb738f Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 22 Jan 2026 11:01:32 -0600 Subject: [PATCH 34/36] Fix prettier issue --- .../workflows/src/arm-auto-signoff/arm-auto-signoff-code.js | 4 +--- .../src/arm-auto-signoff/arm-auto-signoff-status.js | 2 +- .../test/arm-auto-signoff/arm-auto-signoff-status.test.js | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 9dc32b347210..f391644e190d 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -14,9 +14,7 @@ import { checkTrivialChanges } from "./trivial-changes-check.js"; * @param {import("@actions/github-script").AsyncFunctionArguments} args * @returns {Promise<{ incremental: boolean, trivial: boolean }>} */ -export default async function armAutoSignoffCode({core}) { - - +export default async function armAutoSignoffCode({ core }) { core.info("Starting ARM Auto Signoff Code analysis."); // Run incremental TypeSpec check diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js index 7eec38724b68..63a01865463b 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-status.js @@ -3,8 +3,8 @@ import { CommitStatusState, PER_PAGE_MAX } from "../../../shared/src/github.js"; import { equals } from "../../../shared/src/set.js"; import { byDate, invert } from "../../../shared/src/sort.js"; import { extractInputs } from "../context.js"; -import { ArmAutoSignoffLabel } from "./arm-auto-signoff-labels.js"; import { LabelAction } from "../label.js"; +import { ArmAutoSignoffLabel } from "./arm-auto-signoff-labels.js"; /** @typedef {import('@octokit/plugin-rest-endpoint-methods').RestEndpointMethodTypes} RestEndpointMethodTypes */ /** @typedef {RestEndpointMethodTypes["issues"]["listLabelsOnIssue"]["response"]["data"][number]} IssueLabel */ diff --git a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js index 659fdee5aac2..e1857dd82539 100644 --- a/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js +++ b/.github/workflows/test/arm-auto-signoff/arm-auto-signoff-status.test.js @@ -545,10 +545,7 @@ describe("getLabelActionImpl", () => { // Invalid boolean value should be treated as missing/invalid. github.rest.actions.listWorkflowRunArtifacts.mockResolvedValue({ data: { - artifacts: [ - { name: "incremental-typespec=maybe" }, - { name: "trivial-changes=false" }, - ], + artifacts: [{ name: "incremental-typespec=maybe" }, { name: "trivial-changes=false" }], }, }); From cccd3789b7ec6351cbb24cb4bb7092ede646aad7 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 22 Jan 2026 15:35:14 -0600 Subject: [PATCH 35/36] fix space --- .github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index f391644e190d..00500f04713f 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -14,7 +14,7 @@ import { checkTrivialChanges } from "./trivial-changes-check.js"; * @param {import("@actions/github-script").AsyncFunctionArguments} args * @returns {Promise<{ incremental: boolean, trivial: boolean }>} */ -export default async function armAutoSignoffCode({ core }) { +export default async function armAutoSignoffCode({core}) { core.info("Starting ARM Auto Signoff Code analysis."); // Run incremental TypeSpec check From b46058aac3e2663664006ecca4aa9184ed13e922 Mon Sep 17 00:00:00 2001 From: akhilailla Date: Thu, 22 Jan 2026 16:45:09 -0600 Subject: [PATCH 36/36] fix prettier issue --- .github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js index 00500f04713f..f391644e190d 100644 --- a/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js +++ b/.github/workflows/src/arm-auto-signoff/arm-auto-signoff-code.js @@ -14,7 +14,7 @@ import { checkTrivialChanges } from "./trivial-changes-check.js"; * @param {import("@actions/github-script").AsyncFunctionArguments} args * @returns {Promise<{ incremental: boolean, trivial: boolean }>} */ -export default async function armAutoSignoffCode({core}) { +export default async function armAutoSignoffCode({ core }) { core.info("Starting ARM Auto Signoff Code analysis."); // Run incremental TypeSpec check