Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .github/workflows/sdk-breaking-change-labels.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: SDK Breaking Change Labels

on:
Expand All @@ -6,22 +6,39 @@

permissions:
contents: read
pull-requests: read
id-token: write

jobs:
sdk-breaking-change-labels:
# Only run this job when the check run is from Azure Pipelines SDK Generation job in the azure-sdk/public(id: 29ec6040-b234-4e31-b139-33dc4287b756) project
# Only run this job when the check run is 'SDK Validation *'
if: |
github.event.check_run.check_suite.app.name == 'Azure Pipelines' &&
contains(github.event.check_run.name, 'SDK Validation') &&
endsWith(github.event.check_run.external_id, '29ec6040-b234-4e31-b139-33dc4287b756')
contains(github.event.check_run.name, 'SDK Validation')
name: SDK Breaking Change Labels
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
.github


# Only run the login and get token steps when the respository is azure-rest-api-specs-pr
- if: github.event.repository.name == 'azure-rest-api-specs-pr'
name: Azure Login with Workload Identity Federation
uses: azure/login@v2
with:
client-id: "936c56f0-298b-467f-b702-3ad5bf4b15c1"
tenant-id: "72f988bf-86f1-41af-91ab-2d7cd011db47"
allow-no-subscriptions: true

- if: github.event.repository.name == 'azure-rest-api-specs-pr'
name: Get ADO Token via Managed Identity
run: |
# Get token for Azure DevOps resource
ADO_TOKEN=$(az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query "accessToken" -o tsv)
echo "ADO_TOKEN=$ADO_TOKEN" >> $GITHUB_ENV

- name: Get label and action
id: get-label-and-action
uses: actions/github-script@v7
Expand All @@ -42,8 +59,9 @@
value: "${{ fromJson(steps.get-label-and-action.outputs.result).labelAction == 'add' }}"

- if: |
(fromJson(steps.get-label-and-action.outputs.result).labelAction == 'add' ||
fromJson(steps.get-label-and-action.outputs.result).labelAction == 'remove')
((fromJson(steps.get-label-and-action.outputs.result).labelAction == 'add' ||
fromJson(steps.get-label-and-action.outputs.result).labelAction == 'remove') &&
fromJson(steps.get-label-and-action.outputs.result).issueNumber > 0)
name: Upload artifact with issue number
uses: ./.github/actions/add-empty-artifact
with:
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/src/artifacts.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// @ts-check
import { fetchWithRetry } from "./retries.js";

Expand Down Expand Up @@ -188,7 +188,14 @@
.filter((artifact) => artifact.name.includes(artifactName))
.sort((a, b) => b.name.localeCompare(a.name)); // Descending order (Z to A)
if (artifactsList.length === 0) {
throw new Error(`No artifacts found with name containing ${artifactName}`);
const message = `No artifacts found with name containing ${artifactName}`;
core.warning(message);
// Return a Response-like object using the global Response constructor
return new Response(message, {
status: 404,
statusText: message,
headers: { "Content-Type": "text/plain" },
});
}
artifactName = artifactsList[0].name;
apiUrl = `${ado_project_url}/_apis/build/builds/${ado_build_id}/artifacts?artifactName=${artifactName}&api-version=7.0`;
Expand Down
17 changes: 3 additions & 14 deletions .github/workflows/src/context.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// @ts-check

import { PER_PAGE_MAX } from "./github.js";
Expand All @@ -10,7 +10,7 @@
* @param {import('github-script').AsyncFunctionArguments['github']} github
* @param {import('github-script').AsyncFunctionArguments['context']} context
* @param {import('github-script').AsyncFunctionArguments['core']} core
* @returns {Promise<{owner: string, repo: string, head_sha: string, issue_number: number, run_id: number, ado_project_url?: string, ado_build_id?: string }>}
* @returns {Promise<{owner: string, repo: string, head_sha: string, issue_number: number, run_id: number, details_url?: string }>}
*/
export async function extractInputs(github, context, core) {
core.info("extractInputs()");
Expand All @@ -24,7 +24,7 @@
// with debug enabled to replay the previous context.
core.isDebug() && core.debug(`context: ${JSON.stringify(context)}`);

/** @type {{ owner: string, repo: string, head_sha: string, issue_number: number, run_id: number, ado_project_url?: string, ado_build_id?: string }} */
/** @type {{ owner: string, repo: string, head_sha: string, issue_number: number, run_id: number, details_url?: string }} */
let inputs;

// Add support for more event types as needed
Expand Down Expand Up @@ -240,16 +240,6 @@
};
} else if (context.eventName === "check_run") {
let checkRun = context.payload.check_run;

// Extract the ADO build ID and project URL from the check run details URL
const buildUrlRegex = /^(.*?)(?=\/_build\/).*?[?&]buildId=(\d+)/;
const match = checkRun.details_url.match(buildUrlRegex);
if (!match) {
throw new Error(
`Could not extract build ID or project URL from check run details URL: ${checkRun.details_url}`,
);
}

const payload =
/** @type {import("@octokit/webhooks-types").CheckRunEvent} */ (
context.payload
Expand All @@ -259,8 +249,7 @@
owner: repositoryInfo.owner,
repo: repositoryInfo.repo,
head_sha: checkRun.head_sha,
ado_build_id: match[2],
ado_project_url: match[1],
details_url: checkRun.details_url,
issue_number: NaN,
run_id: NaN,
};
Expand Down
121 changes: 37 additions & 84 deletions .github/workflows/src/sdk-breaking-change-labels.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// @ts-check
import { sdkLabels } from "../../shared/src/sdk-types.js";
import {
getAdoBuildInfoFromUrl,
getAzurePipelineArtifact,
} from "./artifacts.js";
import { extractInputs } from "./context.js";
import { getIssueNumber } from "./issues.js";
import { LabelAction } from "./label.js";
import { fetchWithRetry } from "./retries.js";

/**
* @typedef {import("../../shared/src/sdk-types.js").SdkName} SdkName
Expand All @@ -25,39 +27,28 @@
*/
export async function getLabelAndAction({ github, context, core }) {
const inputs = await extractInputs(github, context, core);
const ado_build_id = inputs.ado_build_id;
const ado_project_url = inputs.ado_project_url;
const head_sha = inputs.head_sha;
if (!ado_build_id || !ado_project_url || !head_sha) {
const details_url = inputs.details_url;
if (!details_url) {
throw new Error(
`Required inputs are not valid: ado_build_id:${ado_build_id}, ado_project_url:${ado_project_url}, head_sha:${head_sha}`,
`Required inputs are not valid: details_url:${details_url}`,
);
}
return await getLabelAndActionImpl({
ado_build_id,
ado_project_url,
head_sha,
details_url,
core,
github,
});
}

/**
* @param {Object} params
* @param {string} params.ado_build_id
* @param {string} params.ado_project_url
* @param {string} params.head_sha
* @param {string} params.details_url
* @param {typeof import("@actions/core")} params.core
* @param {(import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/types.js").Api)} params.github
* @param {import('./retries.js').RetryOptions} [params.retryOptions]
* @returns {Promise<{labelName: string | undefined, labelAction: LabelAction, issueNumber: number}>}
*/
export async function getLabelAndActionImpl({
ado_build_id,
ado_project_url,
head_sha,
details_url,
core,
github,
retryOptions = {},
}) {
// Override default logger from console.log to core.info
Expand All @@ -67,67 +58,40 @@
let labelAction;
/** @type {String | undefined} */
let labelName = "";
const buildInfo = getAdoBuildInfoFromUrl(details_url);
const ado_project_url = buildInfo.projectUrl;
const ado_build_id = buildInfo.buildId;
const artifactName = "spec-gen-sdk-artifact";
const artifactFileName = artifactName + ".json";
const apiUrl = `${ado_project_url}/_apis/build/builds/${ado_build_id}/artifacts?artifactName=${artifactName}&api-version=7.0`;
core.info(`Calling Azure DevOps API to get the artifact: ${apiUrl}`);

// Use Node.js fetch with retry to call the API
const response = await fetchWithRetry(
apiUrl,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
const result = await getAzurePipelineArtifact({
ado_build_id,
ado_project_url,
artifactName,
artifactFileName,
core,
retryOptions,
);

if (response.status === 404) {
core.info(
`Artifact '${artifactName}' not found (404). This might be expected if there are no breaking changes.`,
);
} else if (response.ok) {
// Step 1: Get the download URL for the artifact
/** @type {Artifacts} */
const artifacts = /** @type {Artifacts} */ (await response.json());
core.info(`Artifacts found: ${JSON.stringify(artifacts)}`);
if (!artifacts.resource || !artifacts.resource.downloadUrl) {
throw new Error(
`Download URL not found for the artifact ${artifactName}`,
);
}

let downloadUrl = artifacts.resource.downloadUrl;
const index = downloadUrl.indexOf("?format=zip");
if (index !== -1) {
// Keep everything up to (but not including) "?format=zip"
downloadUrl = downloadUrl.substring(0, index);
}
downloadUrl += `?format=file&subPath=/${artifactFileName}`;
core.info(`Downloading artifact from: ${downloadUrl}`);

// Step 2: Fetch Artifact Content (as a Buffer) with retry
const artifactResponse = await fetchWithRetry(
downloadUrl,
{},
{ logger: core.info },
fallbackToFailedArtifact: true,
token: process.env.ADO_TOKEN,
});
// Parse the JSON data
if (!result.artifactData) {
core.warning(
`Artifact '${artifactName}' not found in the build with details_url:${details_url} or failed to download it.`,
);
if (!artifactResponse.ok) {
throw new Error(
`Failed to fetch artifact: ${artifactResponse.statusText}`,
} else {
core.info(`Artifact content: ${result.artifactData}`);
// Parse the JSON data
const specGenSdkArtifactInfo = JSON.parse(result.artifactData);
const labelActionText = specGenSdkArtifactInfo.labelAction;
issue_number = parseInt(specGenSdkArtifactInfo.prNumber, 10);
if (!issue_number) {
core.warning(
`No PR number found in the artifact '${artifactName}' with details_url:${details_url}.`,
);
}

const artifactData = await artifactResponse.text();
core.info(`Artifact content: ${artifactData}`);

// Parse the JSON data
const breakingChangeResult = JSON.parse(artifactData);
const labelActionText = breakingChangeResult.labelAction;
/** @type {SdkName} */
const breakingChangeLanguage = breakingChangeResult.language;
const breakingChangeLanguage = specGenSdkArtifactInfo.language;
if (breakingChangeLanguage) {
labelName = sdkLabels[`${breakingChangeLanguage}`].breakingChange;
}
Expand All @@ -138,19 +102,8 @@
} else if (labelActionText === false) {
labelAction = LabelAction.Remove;
}

// Get the issue number from the check run
if (!issue_number) {
const { issueNumber } = await getIssueNumber({ head_sha, core, github });
issue_number = issueNumber;
}
} else {
core.error(
`Failed to fetch artifacts: ${response.status}, ${response.statusText}`,
);
const errorText = await response.text();
core.error(`Error details: ${errorText}`);
}

if (!labelAction) {
core.info("No label action found, defaulting to None");
labelAction = LabelAction.None;
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/src/spec-gen-sdk-status.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// @ts-check
import { extractInputs } from "./context.js";
import {
Expand All @@ -17,12 +17,11 @@
*/
export default async function setSpecGenSdkStatus({ github, context, core }) {
const inputs = await extractInputs(github, context, core);
const ado_build_id = inputs.ado_build_id;
const ado_project_url = inputs.ado_project_url;
const head_sha = inputs.head_sha;
if (!ado_build_id || !ado_project_url || !head_sha) {
const details_url = inputs.details_url;
if (!details_url || !head_sha) {
throw new Error(
`Required inputs are not valid: ado_build_id:${ado_build_id}, ado_project_url:${ado_project_url}, head_sha:${head_sha}`,
`Required inputs are not valid: details_url:${details_url}, head_sha:${head_sha}`,
);
}
const owner = inputs.owner;
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/test/artifacts.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
getAzurePipelineArtifact,
Expand Down Expand Up @@ -703,7 +703,7 @@
expect(response).toBe(mockFetchResponse);
});

it("should throw an error when no matching artifacts are found", async () => {
it("should return 404 when no matching artifacts are found", async () => {
// Mock response with no matching artifacts
const mockListResponse = {
ok: true,
Expand All @@ -725,8 +725,11 @@

global.fetch.mockResolvedValue(mockListResponse);

// Call the function and expect it to throw
await expect(fetchFailedArtifact(defaultParams)).rejects.toThrow(
// Call the function and expect it to return a 404 response
const response = await fetchFailedArtifact(defaultParams);
expect(response.ok).toBe(false);
expect(response.status).toBe(404);
expect(response.statusText).toBe(
`No artifacts found with name containing ${defaultParams.artifactName}`,
);
});
Expand Down
16 changes: 4 additions & 12 deletions .github/workflows/test/context.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { describe, expect, it } from "vitest";
import { extractInputs } from "../src/context.js";
import { PER_PAGE_MAX } from "../src/github.js";
Expand Down Expand Up @@ -469,8 +469,8 @@
issue_number: NaN,
head_sha: "abc123",
run_id: NaN,
ado_build_id: "56789",
ado_project_url: "https://dev.azure.com/abc/123-456",
details_url:
"https://dev.azure.com/abc/123-456/_build/results?buildId=56789",
});
});

Expand All @@ -481,26 +481,18 @@
payload: {
action: "completed",
check_run: {
details_url: "https://debc/123-456/_build/result/buildId=56789",
details_url:
"https://dev.azure.com/abc/123-456/_build/results?buildId=56789",
head_sha: "abc123",
},
repository: {
name: "TestRepoName",
owner: {
login: "TestRepoOwnerLogin",
},
},
},
};

await expect(
extractInputs(github, context, createMockCore()),
).rejects.toThrow("from check run details URL");

context.payload.check_run.details_url =
"https://dev.azure.com/abc/123-456/_build/results?buildId=56789";
delete context.payload.repository.name;

await expect(
extractInputs(github, context, createMockCore()),
).rejects.toThrow("from context payload");
Expand Down
Loading