diff --git a/.github/workflows/release-plan-test.yaml b/.github/workflows/release-plan-test.yaml new file mode 100644 index 000000000000..8114880a3a4d --- /dev/null +++ b/.github/workflows/release-plan-test.yaml @@ -0,0 +1,27 @@ +name: release-plan - Test + +on: + push: + branches: + - main + pull_request: + paths: + - package-lock.json + - package.json + - tsconfig.json + - .github/workflows/_reusable-eng-tools-test.yaml + - .github/workflows/release-plan-tests.yaml + - eng/tools/package.json + - eng/tools/tsconfig.json + - eng/tools/release-plan/** + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-plan: + uses: ./.github/workflows/_reusable-eng-tools-test.yaml + with: + package: release-plan + lint: true diff --git a/documentation/release-plan-auto-generation.md b/documentation/release-plan-auto-generation.md new file mode 100644 index 000000000000..770904f66009 --- /dev/null +++ b/documentation/release-plan-auto-generation.md @@ -0,0 +1,181 @@ +# Release Plan Auto-Generation + +This document explains how release plans are automatically discovered or created by the `eng/tools/release-plan` tool and the `eng/pipelines/release-plan.yml` pipeline. + +## Overview + +The release-plan pipeline is a post-merge automation for specification updates. + +High-level lifecycle: + +1. A spec PR merges into `main` in `Azure/azure-rest-api-specs` or `Azure/azure-rest-api-specs-pr`. +2. The pipeline is triggered by changes under `specification/**`. +3. Stage (`ReleasePlanCreation`) runs and resolves the commit SHA to analyze. +4. The tool tries to map the commit to an associated PR and inspects TypeSpec changes. +5. The tool first attempts to find an existing release plan. +6. The tool creates a release plan only when creation is allowed by label policy and if a release plan does not exists. +7. The pipeline always emits a JSON result artifact for downstream stages/jobs. + +From pipeline execution perspective, the release-plan tool operates in this order: + +1. Resolve associated PR from commit SHA (if any). +2. Detect TypeSpec project and API version from changed spec files. +3. Check whether the associated PR has the `new-api-version` label. +4. Find an existing release plan first. +5. Create a new release plan only when allowed. +6. Publish JSON output as a pipeline artifact. + +## Trigger Model (When This Runs) + +Pipeline trigger behavior in `eng/pipelines/release-plan.yml`: + +- `trigger.paths.include: specification/**` +- `pr: none` + +Meaning: + +- This pipeline does not run as a PR-validation pipeline. +- It runs on branch updates (CI trigger) when files under `specification/**` change. +- In the common path, this happens automatically after a spec PR is merged to `main`. + +Commit SHA source: + +1. Primary: `$(Build.SourceVersion)` +2. Fallback: repository `HEAD` via `git rev-parse HEAD` when `Build.SourceVersion` is empty + +So after a merge to `main`, the pipeline analyzes the latest merged commit (or latest `HEAD` if fallback is needed). + +## Inputs + +The tool is invoked with: + +- `--commit-sha`: commit to analyze +- `--repo`: repository in `owner/repo` format +- `--workspace`: local repo path +- `--azsdk-path`: azsdk CLI path +- `--output-file`: optional JSON output path for artifact publishing + +## How TypeSpec Project Is Determined + +Changed files are inspected under `specification/**`. + +Project detection logic: + +1. Prefer changed `tspconfig.yaml` paths. +2. Otherwise, walk up from changed files to find nearest `tspconfig.yaml`. +3. If no project is found: no release plan action. +4. If multiple projects are found: no automatic create (manual handling required). + +## How API Version Is Determined + +API version is selected from detected project changes in this order: + +1. Version-like strings found in changed file paths (`YYYY-MM-DD` or `YYYY-MM-DD-preview`). +2. If not found, scan project `main.tsp` for version-like strings. +3. Sort versions descending and pick the first one. + +Sorting rules: + +- Newer date wins. +- For same date, GA wins over `-preview`. + +Result: + +- Selected value is used as `apiVersion` in release-plan metadata. +- Preview detection (`-preview`) drives SDK release type and API release type mapping. + +## Create vs No-Create Scenarios + +### Scenario A: Associated PR has `new-api-version` label + +Behavior: + +1. Try `release-plan get --pull-request` (if PR URL exists). +2. Try `release-plan get --typespec-path --api-release-type`. +3. If still not found, run `release-plan create`. + +Outcome: + +- Existing plan is returned if found. +- New plan is created only in this scenario. + +### Scenario B: Associated PR does NOT have `new-api-version` label + +Behavior: + +1. Try `release-plan get --pull-request` (if PR URL exists). +2. Try `release-plan get --typespec-path --api-release-type`. +3. Do NOT create a new release plan. + +Outcome: + +- Existing plan is returned if present. +- If no existing plan is found, result is `not_found`. + +### Scenario C: No PR associated with commit SHA + +Behavior: + +- Project/version detection falls back to commit-level file changes. +- PR URL is unavailable. +- Existing-by-path lookup still runs. +- Create is not possible without PR URL and is skipped unless a PR is resolved. + +Outcome: + +- Existing plan may still be returned by path. +- If none found and create is not allowed, result is `not_found`. + +## API Release Type and SDK Release Type + +It uses selected API version status and repo name: + +- Repo `azure-rest-api-specs-pr` -> API release type: `Private Preview` +- Repo `azure-rest-api-specs`: + - preview API version -> `Public Preview` + - stable API version -> `GA` + +SDK release type: + +- preview API version -> `preview` +- stable API version -> `stable` + +## PR Comment Behavior + +PR comment is posted only when: + +- A new release plan is created, and +- An associated PR number is available. + +## Pipeline Artifact Output + +The pipeline writes release-plan result JSON to: + +- `$(Build.ArtifactStagingDirectory)/release-plan/release-plan.json` + +It publishes artifact: + +- `release-plan-details` + +If no file is generated, pipeline creates a fallback JSON: + +- `{"outcome":"not_found","releasePlan":null,"details":{}}` + +## Stage Layout + +Current pipeline stage: + +- Stage 1: `ReleasePlanCreation` + +This stage: + +- Authenticates npm +- Logs into GitHub +- Installs azsdk CLI +- Runs release-plan tool +- Publishes release-plan details artifact + + +## Future enhancement + +Run SDK generation for the release plan as stage 2 using release plan details identified in stage 1. \ No newline at end of file diff --git a/eng/pipelines/release-plan.yml b/eng/pipelines/release-plan.yml new file mode 100644 index 000000000000..3d35bf829d31 --- /dev/null +++ b/eng/pipelines/release-plan.yml @@ -0,0 +1,100 @@ +parameters: + - name: Test-release-Plan + type: boolean + default: false + - name: PrNumber + type: number + default: 0 + +trigger: + paths: + include: + - specification/** + +pr: none + +stages: + - stage: ReleasePlanCreation + displayName: "Stage : Release Plan Creation" + jobs: + - job: ReleasePlan + displayName: Create or Find Release Plan + pool: + name: $(LINUXPOOL) + vmImage: $(LINUXVMIMAGE) + + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - template: /eng/pipelines/templates/variables/image.yml + + steps: + - checkout: self + fetchDepth: 2 + + - template: /eng/common/pipelines/templates/steps/login-to-github.yml + + - template: /eng/pipelines/templates/steps/npm-install.yml + + - template: /eng/common/pipelines/templates/steps/install-azsdk-cli.yml + + - task: AzureCLI@2 + displayName: "Get Release Plan details" + condition: succeeded() + continueOnError: true + env: + GH_TOKEN: $(GH_TOKEN) + inputs: + azureSubscription: opensource-api-connection + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $ErrorActionPreference = "Stop" + + $prNumber = ${{ parameters.PrNumber }} + $outputPath = "$(Build.ArtifactStagingDirectory)/release-plan/release-plan.json" + + $nodeArgs = @( + "--repo", "$(Build.Repository.Name)", + "--workspace", "$(Build.SourcesDirectory)", + "--azsdk-path", "$(AZSDK)", + "--test-release-plan", "${{ parameters['Test-release-Plan'] }}", + "--output-file", $outputPath + ) + + if ($prNumber -gt 0) { + Write-Host "Using PR number: $prNumber" + $nodeArgs += @("--pr-number", $prNumber) + } else { + $commitSha = "$(Build.SourceVersion)" + if ([string]::IsNullOrWhiteSpace($commitSha)) { + Write-Host "Build.SourceVersion is empty. Falling back to repo HEAD commit." + $commitSha = (git -C "$(Build.SourcesDirectory)" rev-parse HEAD).Trim() + } + + if ([string]::IsNullOrWhiteSpace($commitSha)) { + Write-Host "##vso[task.logissue type=error]Unable to determine commit SHA from Build.SourceVersion or repository HEAD." + exit 1 + } + + Write-Host "Using commit SHA: $commitSha" + $nodeArgs += @("--commit-sha", $commitSha) + } + + npm exec --no -- release-plan @nodeArgs + + - pwsh: | + $outputPath = "$(Build.ArtifactStagingDirectory)/release-plan/release-plan.json" + if (!(Test-Path $outputPath)) { + New-Item -ItemType Directory -Force -Path (Split-Path $outputPath -Parent) | Out-Null + '{"outcome":"not_found","releasePlan":null,"details":{}}' | Out-File -FilePath $outputPath -Encoding utf8 + } + displayName: "Ensure release plan artifact file exists" + condition: succeededOrFailed() + + - task: PublishPipelineArtifact@1 + displayName: "Publish release plan details artifact" + condition: succeededOrFailed() + inputs: + targetPath: "$(Build.ArtifactStagingDirectory)/release-plan" + artifactName: "release-plan-details" + publishLocation: "pipeline" diff --git a/eng/tools/package.json b/eng/tools/package.json index 388c60a5da6d..df68eac01635 100644 --- a/eng/tools/package.json +++ b/eng/tools/package.json @@ -5,6 +5,7 @@ "devDependencies": { "@azure-tools/lint-diff": "file:lint-diff", "@azure-tools/oav-runner": "file:oav-runner", + "@azure-tools/release-plan": "file:release-plan", "@azure-tools/sdk-suppressions": "file:sdk-suppressions", "@azure-tools/openapi-diff-runner": "file:openapi-diff-runner", "@azure-tools/spec-gen-sdk-runner": "file:spec-gen-sdk-runner", diff --git a/eng/tools/release-plan/cmd/release-plan.js b/eng/tools/release-plan/cmd/release-plan.js new file mode 100644 index 000000000000..df3429a839a1 --- /dev/null +++ b/eng/tools/release-plan/cmd/release-plan.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from "../src/index.ts"; + +await main(); diff --git a/eng/tools/release-plan/eslint.config.js b/eng/tools/release-plan/eslint.config.js new file mode 100644 index 000000000000..fe00d3ea6f5a --- /dev/null +++ b/eng/tools/release-plan/eslint.config.js @@ -0,0 +1,7 @@ +// @ts-check + +import { defineBaseConfig } from "../eslint.base.config.js"; + +export default defineBaseConfig({ + tsconfigRootDir: import.meta.dirname, +}); diff --git a/eng/tools/release-plan/package.json b/eng/tools/release-plan/package.json new file mode 100644 index 000000000000..b70442545bf4 --- /dev/null +++ b/eng/tools/release-plan/package.json @@ -0,0 +1,38 @@ +{ + "name": "@azure-tools/release-plan", + "private": true, + "type": "module", + "main": "src/index.ts", + "bin": { + "release-plan": "cmd/release-plan.js" + }, + "dependencies": { + "@octokit/rest": "^22.0.0", + "@azure-tools/specs-shared": "file:../../../.github/shared" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.1.0", + "cross-env": "^10.1.0", + "eslint": "^10.0.0", + "prettier": "3.8.3", + "prettier-plugin-organize-imports": "^4.2.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vitest": "^4.1.0" + }, + "scripts": { + "build": "tsc --noEmit", + "check": "npm run build && npm run lint && npm run format:check && npm run test:ci", + "format": "prettier . --ignore-path ../.prettierignore --write", + "format:check": "prettier . --ignore-path ../.prettierignore --check", + "format:check:ci": "prettier . --ignore-path ../.prettierignore --check --log-level debug", + "lint": "cross-env DEBUG=eslint:eslint,eslint:linter eslint", + "test": "vitest", + "test:ci": "vitest run --coverage --reporter=verbose" + }, + "engines": { + "node": ">=24.14.1" + } +} diff --git a/eng/tools/release-plan/src/args.ts b/eng/tools/release-plan/src/args.ts new file mode 100644 index 000000000000..69e03291a10f --- /dev/null +++ b/eng/tools/release-plan/src/args.ts @@ -0,0 +1,117 @@ +import path from "node:path"; +import process from "node:process"; +import { parseArgs, type ParseArgsConfig } from "node:util"; +import type { CliArguments } from "./types.ts"; + +/** + * Parse command line arguments for release plan tool. + */ +export function parseCliArguments(argv: string[] = process.argv.slice(2)): CliArguments { + const options: ParseArgsConfig = { + args: argv, + options: { + "commit-sha": { + type: "string", + short: "c", + }, + "pr-number": { + type: "string", + }, + repo: { + type: "string", + short: "r", + default: process.env.GITHUB_REPOSITORY || "Azure/azure-rest-api-specs", + }, + workspace: { + type: "string", + short: "w", + default: process.cwd(), + }, + "azsdk-path": { + type: "string", + }, + "output-file": { + type: "string", + }, + "test-release-plan": { + type: "string", + default: "false", + }, + help: { + type: "boolean", + short: "h", + default: false, + }, + }, + allowPositionals: false, + strict: true, + }; + + const { values } = parseArgs(options); + + if (values.help) { + showHelp(); + process.exit(0); + } + + const commitSha = String(values["commit-sha"] ?? "").trim() || undefined; + const prNumberRaw = String(values["pr-number"] ?? "").trim(); + const prNumber = prNumberRaw ? parseInt(prNumberRaw, 10) : undefined; + + if (!commitSha && !prNumber) { + throw new Error("Either --commit-sha or --pr-number is required."); + } + + if (prNumber && isNaN(prNumber)) { + throw new Error("--pr-number must be a valid number."); + } + + const repoRaw = String(values.repo ?? "").trim(); + const [owner, repo] = repoRaw.split("/"); + if (!owner || !repo) { + throw new Error("--repo must be in the form 'owner/repo'."); + } + + const workspace = path.resolve(String(values.workspace ?? process.cwd())); + const azsdkPath = String(values["azsdk-path"] ?? "").trim() || undefined; + const outputFile = String(values["output-file"] ?? "").trim() || undefined; + const testReleaseRaw = String(values["test-release-plan"] ?? "false") + .trim() + .toLowerCase(); + if (testReleaseRaw !== "true" && testReleaseRaw !== "false") { + throw new Error("--test-release-plan must be either 'true' or 'false'."); + } + const testReleasePlan = testReleaseRaw === "true"; + + return { + commitSha, + prNumber, + owner, + repo, + workspace, + azsdkPath, + outputFile, + testReleasePlan, + }; +} + +function showHelp(): void { + console.log("Release Plan Tool"); + console.log(""); + console.log("Usage:"); + console.log(" release-plan [--commit-sha | --pr-number ] [options]"); + console.log(""); + console.log("Options:"); + console.log(" -c, --commit-sha Commit SHA to resolve PR or analyze changed files"); + console.log( + " --pr-number PR number to analyze directly (alternative to --commit-sha)", + ); + console.log(" -r, --repo GitHub repository in owner/repo format"); + console.log(" -w, --workspace Path to local repo root (default: cwd)"); + console.log(" --azsdk-path Absolute path to the azsdk executable"); + console.log(" --output-file Write JSON result to this file path"); + console.log( + " --test-release-plan Create release plan as test (true|false, default: false)", + ); + console.log(" -h, --help Show help"); +} diff --git a/eng/tools/release-plan/src/index.ts b/eng/tools/release-plan/src/index.ts new file mode 100644 index 000000000000..8c659b3a67c9 --- /dev/null +++ b/eng/tools/release-plan/src/index.ts @@ -0,0 +1,180 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseCliArguments } from "./args.ts"; +import { postReleasePlanComment } from "./pr-comment.ts"; +import { + createAzdskRunner, + ensureReleasePlan, + getApiReleaseType, + getNextMonthTarget, + getSdkReleaseType, +} from "./release-plan.ts"; +import type { TypeSpecProjectInfo } from "./types.ts"; +import { + checkNewApiVersionLabel, + createOctokit, + getTypeSpecProjectInfoFromCommit, + getTypeSpecProjectInfoFromPr, +} from "./typespec-project.ts"; + +/** + * Main CLI entry point. + */ +export async function main(): Promise { + try { + const args = parseCliArguments(); + const octokit = createOctokit(undefined); + + let projectInfo: TypeSpecProjectInfo | null; + let resolvedPrNumber: number | undefined; + let hasNewApiVersionLabel: boolean; + + // Use provided PR number if available, otherwise fall back to commit SHA + if (args.prNumber) { + console.log(`Analyzing PR #${args.prNumber} in ${args.owner}/${args.repo}`); + + projectInfo = await getTypeSpecProjectInfoFromPr({ + prNumber: args.prNumber, + owner: args.owner, + repo: args.repo, + workspace: args.workspace, + octokit, + }); + + resolvedPrNumber = args.prNumber; + + hasNewApiVersionLabel = await checkNewApiVersionLabel({ + octokit, + owner: args.owner, + repo: args.repo, + prNumber: args.prNumber, + }); + } else { + const commitSha = args.commitSha as string; + console.log(`Analyzing commit ${commitSha} in ${args.owner}/${args.repo}`); + + const commitResult = await getTypeSpecProjectInfoFromCommit({ + commitSha, + owner: args.owner, + repo: args.repo, + workspace: args.workspace, + octokit, + }); + + projectInfo = commitResult.projectInfo; + resolvedPrNumber = commitResult.prNumber; + hasNewApiVersionLabel = commitResult.hasNewApiVersionLabel; + } + + const prUrl = resolvedPrNumber + ? `https://github.com/${args.owner}/${args.repo}/pull/${resolvedPrNumber}` + : undefined; + + if (projectInfo === null) { + console.log("There are no TypeSpec changes in the PR or commit."); + process.exit(0); + } + + console.log( + `Found TypeSpec project at ${projectInfo.tspProjectPath} with API version ${projectInfo.apiVersion}`, + ); + const apiReleaseType = getApiReleaseType(projectInfo.isPreview, args.repo); + const sdkReleaseType = getSdkReleaseType(projectInfo.isPreview); + const targetMonth = getNextMonthTarget(); + + const result = ensureReleasePlan( + { + prUrl, + tspProjectPath: projectInfo.tspProjectPath, + apiReleaseType, + sdkReleaseType, + targetMonth, + apiVersion: projectInfo.apiVersion, + testReleasePlan: args.testReleasePlan, + }, + createAzdskRunner(args.azsdkPath), + hasNewApiVersionLabel, + ); + + console.log(JSON.stringify(result, null, 2)); + + if (args.outputFile) { + const outputPath = path.resolve(args.outputFile); + mkdirSync(path.dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`Wrote release plan details to ${outputPath}`); + } + + // Post comment on PR if release plan was created + if (result.outcome === "created" && resolvedPrNumber) { + try { + const planLinkValue = result.releasePlan?.release_plan_link; + const planIdValue = result.releasePlan?.ReleasePlanId; + const planLink = typeof planLinkValue === "string" ? planLinkValue : ""; + const planId = + typeof planIdValue === "string" || typeof planIdValue === "number" ? planIdValue : ""; + + await postReleasePlanComment({ + octokit, + owner: args.owner, + repo: args.repo, + prNumber: resolvedPrNumber, + planId, + planLink, + apiVersion: projectInfo.apiVersion, + tspProjectPath: projectInfo.tspProjectPath, + }); + + console.log("Posted release plan comment on PR."); + } catch (commentError) { + const message = commentError instanceof Error ? commentError.message : String(commentError); + console.warn(`Warning: Failed to post comment on PR: ${message}`); + } + } else if (result.outcome === "created") { + console.log("Release plan created, but no associated PR was found. Skipping PR comment."); + } else if (result.outcome === "not_found") { + console.log("new-api-version label not present and no existing release plan was found."); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`release-plan tool failed: ${message}`); + process.exit(1); + } +} + +export { parseCliArguments } from "./args.ts"; +export { buildReleaseplanCommentBody, postReleasePlanComment } from "./pr-comment.ts"; +export type { CommentBodyParams, PrCommentParams } from "./pr-comment.ts"; +export { + createAzdskRunner, + ensureReleasePlan, + getApiReleaseType, + getNextMonthTarget, + getSdkReleaseType, + runAzdskCommand, +} from "./release-plan.ts"; +export type { + ApiReleaseType, + AzsdkRunner, + CliArguments, + CommandResult, + EnsureReleasePlanResult, + OctokitLike, + PullRequestChangedFile, + ReleasePlanCommandContext, + TypeSpecProjectInfo, +} from "./types.ts"; +export { + collectTypeSpecProjectPaths, + compareApiVersionsDesc, + createOctokit, + detectApiVersions, + findTspConfigDir, + getAssociatedPrNumber, + getCommitChangedFiles, + getPrChangedFiles, + getTypeSpecProjectInfoFromCommit, + getTypeSpecProjectInfoFromPr, + parseApiVersion, +} from "./typespec-project.ts"; diff --git a/eng/tools/release-plan/src/pr-comment.ts b/eng/tools/release-plan/src/pr-comment.ts new file mode 100644 index 000000000000..25bc4844e6f2 --- /dev/null +++ b/eng/tools/release-plan/src/pr-comment.ts @@ -0,0 +1,116 @@ +/** + * Parameters for creating a PR comment about release plan creation. + */ +export interface PrCommentParams { + /** Octokit instance for GitHub API calls */ + octokit: { + rest: { + issues?: { + createComment: (params: { + owner: string; + repo: string; + issue_number: number; + body: string; + }) => Promise; + }; + }; + }; + /** GitHub repository owner */ + owner: string; + /** GitHub repository name */ + repo: string; + /** Pull request number */ + prNumber: number; + /** Release plan ID or work item ID */ + planId?: string | number; + /** Release plan URL or link */ + planLink?: string; + /** API version for the release */ + apiVersion: string; + /** TypeSpec project path */ + tspProjectPath: string; +} + +/** + * Create a comment on a GitHub PR announcing the release plan creation. + * @param params - Comment parameters including PR details and release plan info + * @throws Error if GitHub API call fails + * @example + * await postReleasePlanComment({ + * octokit, + * owner: "Azure", + * repo: "azure-rest-api-specs", + * prNumber: 42, + * planId: "123", + * planLink: "https://example.com/plan/123", + * apiVersion: "2025-06-01-preview", + * tspProjectPath: "specification/foo/Contoso.Service" + * }); + */ +export async function postReleasePlanComment(params: PrCommentParams): Promise { + const { octokit, owner, repo, prNumber, planId, planLink, apiVersion, tspProjectPath } = params; + + const body = buildReleaseplanCommentBody({ + planId, + planLink, + apiVersion, + tspProjectPath, + }); + + try { + if (!octokit.rest.issues?.createComment) { + throw new Error("Octokit issues.createComment is not available."); + } + + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to post release plan comment on PR #${prNumber}: ${message}`, { + cause: error, + }); + } +} + +/** + * Build a formatted markdown comment body for release plan creation announcement. + */ +export interface CommentBodyParams { + /** Release plan ID or work item ID */ + planId?: string | number; + /** Release plan URL or link */ + planLink?: string; + /** API version for the release */ + apiVersion: string; + /** TypeSpec project path */ + tspProjectPath: string; +} + +/** + * Build the markdown body for a release plan creation comment. + * @param params - Parameters for the comment + * @returns Formatted markdown comment body + */ +export function buildReleaseplanCommentBody(params: CommentBodyParams): string { + const { planId, planLink, apiVersion, tspProjectPath } = params; + + let body = `### ✅ Release Plan Created\n\n`; + body += `| Field | Value |\n|-------|-------|\n`; + + if (planLink) { + body += `| **Release Plan** | [View Release Plan](${planLink}) |\n`; + } + + if (planId) { + body += `| **Release Plan ID** | ${planId} |\n`; + } + + body += `| **API Version** | \`${apiVersion}\` |\n`; + body += `| **TypeSpec Project** | \`${tspProjectPath}\` |\n`; + + return body; +} diff --git a/eng/tools/release-plan/src/release-plan.ts b/eng/tools/release-plan/src/release-plan.ts new file mode 100644 index 000000000000..2b7da47aabea --- /dev/null +++ b/eng/tools/release-plan/src/release-plan.ts @@ -0,0 +1,276 @@ +import { spawnSync } from "node:child_process"; +import path, { join } from "node:path"; +import process from "node:process"; +import type { + ApiReleaseType, + AzsdkRunner, + CommandResult, + EnsureReleasePlanResult, + ReleasePlanCommandContext, + ReleasePlanData, +} from "./types.ts"; + +/** + * Create a runner that invokes azsdk from a specific executable path. + * @param azsdkPath Optional full path to the azsdk executable + * @returns Runner function that executes azsdk commands + */ +export function createAzdskRunner(azsdkPath?: string): AzsdkRunner { + return (args: string[]) => runAzdskCommand(args, azsdkPath); +} + +/** + * Ensures release plan exists: by PR first, then by path+release type, else create one. + * @param context The release plan command context containing PR, project, and release info + * @param runner Function to execute azsdk release-plan commands + * @returns Result indicating whether plan was found by PR, by path, or newly created + */ +export function ensureReleasePlan( + context: ReleasePlanCommandContext, + runner: AzsdkRunner, + allowCreate = true, +): EnsureReleasePlanResult { + if (context.prUrl) { + const existingByPr = runGetReleasePlanByPr(context.prUrl, runner); + if (existingByPr) { + return { + outcome: "existing_by_pr", + releasePlan: existingByPr, + details: buildDetails(context), + }; + } + } + + const existingByPath = runGetReleasePlanByPath( + context.tspProjectPath, + context.apiReleaseType, + runner, + ); + if (existingByPath) { + return { + outcome: "existing_by_path", + releasePlan: existingByPath, + details: buildDetails(context), + }; + } + + if (!allowCreate) { + return { + outcome: "not_found", + releasePlan: null, + details: buildDetails(context), + }; + } + + const created = runCreateReleasePlan(context, runner); + return { + outcome: "created", + releasePlan: created, + details: buildDetails(context), + }; +} + +/** + * Builds details object for release plan result. + * @param context The release plan command context + * @returns Details object with PR, project, version, and release info + */ +function buildDetails(context: ReleasePlanCommandContext): EnsureReleasePlanResult["details"] { + return { + prUrl: context.prUrl ?? "", + tspProjectPath: context.tspProjectPath, + apiVersion: context.apiVersion, + apiReleaseType: context.apiReleaseType, + sdkReleaseType: context.sdkReleaseType, + targetReleaseMonth: context.targetMonth, + }; +} + +/** + * Retrieves release plan by pull request URL. + * @param prUrl GitHub PR URL (e.g., https://github.com/owner/repo/pull/123) + * @param runner Function to execute azsdk commands + * @returns Release plan object if found, null if not found or error occurred + */ +function runGetReleasePlanByPr(prUrl: string, runner: AzsdkRunner): ReleasePlanData | null { + const args = ["release-plan", "get", "--pull-request", prUrl, "--output", "json"]; + return parseReleasePlanResult(runner(args)); +} + +/** + * Retrieves release plan by TypeSpec project path and API release type. + * @param tspProjectPath Path to TypeSpec project (relative to workspace) + * @param apiReleaseType API release type (Private Preview, Public Preview, or GA) + * @param runner Function to execute azsdk commands + * @returns Release plan object if found, null if not found or error occurred + */ +function runGetReleasePlanByPath( + tspProjectPath: string, + apiReleaseType: ApiReleaseType, + runner: AzsdkRunner, +): ReleasePlanData | null { + const args = [ + "release-plan", + "get", + "--typespec-path", + tspProjectPath, + "--api-release-type", + apiReleaseType, + "--output", + "json", + ]; + return parseReleasePlanResult(runner(args)); +} + +/** + * Parses release plan command result. + * @param result Command execution result with exit code and output + * @returns Parsed release plan object if successful, null if failed or empty + */ +function parseReleasePlanResult(result: CommandResult): ReleasePlanData | null { + if (result.exitCode !== 0) { + return null; + } + + try { + const parsed: unknown = JSON.parse(result.stdout); + if (parsed === null) { + return null; + } + if (typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as ReleasePlanData; + } catch { + return null; + } +} + +/** + * Creates a new release plan. + * @param context The release plan command context with all required parameters + * @param runner Function to execute azsdk commands + * @returns Created release plan object + * @throws Error if creation fails or output cannot be parsed + */ +function runCreateReleasePlan( + context: ReleasePlanCommandContext, + runner: AzsdkRunner, +): ReleasePlanData { + if (!context.prUrl) { + throw new Error( + "No pull request URL could be resolved for this commit; cannot create release plan.", + ); + } + + const args = [ + "release-plan", + "create", + "--typespec-path", + context.tspProjectPath, + "--api-release-type", + context.apiReleaseType, + "--sdk-type", + context.sdkReleaseType, + "--release-month", + context.targetMonth, + "--pull-request", + context.prUrl, + "--force", + "false", + "--test-release", + String(context.testReleasePlan), + "--output", + "json", + ]; + + const result = runner(args); + if (result.exitCode !== 0) { + throw new Error(`Release plan create failed. ${result.stderr || result.stdout}`); + } + + try { + const parsed: unknown = JSON.parse(result.stdout); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected JSON object from azsdk output: ${result.stdout}`); + } + return parsed as ReleasePlanData; + } catch { + throw new Error(`Failed to parse JSON from azsdk output: ${result.stdout}`); + } +} + +/** + * Run azsdk command synchronously. + * @param args Command arguments to pass to azsdk + * @param azsdkPath Optional full path to the azsdk executable + * @returns Command execution result with exit code and output + */ +export function runAzdskCommand(args: string[], azsdkPath?: string): CommandResult { + const envPath = process.env.PATH || ""; + const home = process.env.HOME || process.env.USERPROFILE || ""; + const homeBin = home ? join(home, "bin") : ""; + const mergedPath = homeBin ? `${homeBin}${path.delimiter}${envPath}` : envPath; + const executable = azsdkPath?.trim() || "azsdk"; + + const result = spawnSync(executable, args, { + encoding: "utf8", + env: { + ...process.env, + PATH: mergedPath, + }, + }); + + return { + exitCode: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +/** + * Computes the target release month as "Month YYYY" for next month. + * @returns Target release month string (e.g., "July 2026") + */ +export function getNextMonthTarget(): string { + const now = new Date(); + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + return `${monthNames[nextMonth.getMonth()]} ${nextMonth.getFullYear()}`; +} + +/** + * Determine API release type based on repo and preview status. + * Private Preview for azure-rest-api-specs-pr repo, otherwise Public Preview or GA. + * @param isPreview Whether the API version is a preview version + * @param repoName Repository name (azure-rest-api-specs or azure-rest-api-specs-pr) + * @returns API release type (Private Preview, Public Preview, or GA) + */ +export function getApiReleaseType(isPreview: boolean, repoName: string): ApiReleaseType { + if (repoName === "azure-rest-api-specs-pr") { + return "Private Preview"; + } + return isPreview ? "Public Preview" : "GA"; +} + +/** + * Determine SDK release type based on preview status. + * @param isPreview Whether the API version is a preview version + * @returns SDK release type (preview or stable) + */ +export function getSdkReleaseType(isPreview: boolean): "preview" | "stable" { + return isPreview ? "preview" : "stable"; +} diff --git a/eng/tools/release-plan/src/types.ts b/eng/tools/release-plan/src/types.ts new file mode 100644 index 000000000000..18caaa61fe09 --- /dev/null +++ b/eng/tools/release-plan/src/types.ts @@ -0,0 +1,108 @@ +export type ApiReleaseType = "Private Preview" | "Public Preview" | "GA"; + +export interface PullRequestChangedFile { + filename: string; + status?: string; +} + +export interface TypeSpecProjectInfo { + tspProjectPath: string; + apiVersion: string; + isPreview: boolean; +} + +export interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export type AzsdkRunner = (args: string[]) => CommandResult; + +export interface ReleasePlanCommandContext { + prUrl?: string; + tspProjectPath: string; + apiReleaseType: ApiReleaseType; + sdkReleaseType: "preview" | "stable"; + targetMonth: string; + apiVersion: string; + testReleasePlan: boolean; +} + +export interface ReleasePlanData extends Record { + release_plan_link?: string; + ReleasePlanId?: string | number; +} + +export interface EnsureReleasePlanResult { + outcome: "existing_by_pr" | "existing_by_path" | "created" | "not_found"; + releasePlan: ReleasePlanData | null; + details: { + prUrl: string; + tspProjectPath: string; + apiVersion: string; + apiReleaseType: ApiReleaseType; + sdkReleaseType: "preview" | "stable"; + targetReleaseMonth: string; + }; +} + +export interface CommitProjectInfoResult { + projectInfo: TypeSpecProjectInfo | null; + prNumber?: number; + hasNewApiVersionLabel: boolean; +} + +export interface CliArguments { + commitSha?: string; + prNumber?: number; + owner: string; + repo: string; + workspace: string; + azsdkPath?: string; + outputFile?: string; + testReleasePlan: boolean; +} + +export interface OctokitLike { + rest: { + pulls: { + get: (params: { owner: string; repo: string; pull_number: number }) => Promise<{ + data: { + labels?: Array<{ name: string }>; + }; + }>; + listFiles: (params: { + owner: string; + repo: string; + pull_number: number; + per_page: number; + page: number; + }) => Promise<{ + data: Array<{ filename: string; status?: string }>; + }>; + }; + repos?: { + listPullRequestsAssociatedWithCommit: (params: { + owner: string; + repo: string; + commit_sha: string; + }) => Promise<{ + data: Array<{ number: number }>; + }>; + getCommit: (params: { owner: string; repo: string; ref: string }) => Promise<{ + data: { + files?: Array<{ filename: string; status?: string }>; + }; + }>; + }; + issues?: { + createComment: (params: { + owner: string; + repo: string; + issue_number: number; + body: string; + }) => Promise; + }; + }; +} diff --git a/eng/tools/release-plan/src/typespec-project.ts b/eng/tools/release-plan/src/typespec-project.ts new file mode 100644 index 000000000000..14dc21199c4a --- /dev/null +++ b/eng/tools/release-plan/src/typespec-project.ts @@ -0,0 +1,479 @@ +import { Octokit } from "@octokit/rest"; +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join, posix } from "node:path"; +import type { + CommitProjectInfoResult, + OctokitLike, + PullRequestChangedFile, + TypeSpecProjectInfo, +} from "./types.ts"; + +/** + * Identifies one TypeSpec project path and selected API version from a pull request. + * Returns null when no specification files were modified, or when zero/multiple projects found. + * @param params Object containing PR details, owner, repo, workspace, and Octokit instance + * @param params.prNumber Pull request number + * @param params.owner Repository owner + * @param params.repo Repository name + * @param params.workspace Absolute path to workspace root + * @param params.octokit Octokit instance for GitHub API calls + * @returns TypeSpec project info with path and API version, or null if no spec changes, no projects found, or multiple projects found + * @throws Error if no API version detected in project + */ +export async function getTypeSpecProjectInfoFromPr(params: { + prNumber: number; + owner: string; + repo: string; + workspace: string; + octokit: OctokitLike; +}): Promise { + const { prNumber, owner, repo, workspace, octokit } = params; + + const allFiles = await getPrChangedFiles({ + octokit, + owner, + repo, + prNumber, + }); + + const specFiles = allFiles.filter((f) => f.filename.startsWith("specification/")); + if (specFiles.length === 0) { + return null; + } + + const tspProjectPaths = collectTypeSpecProjectPaths(specFiles, workspace); + + if (tspProjectPaths.length === 0) { + console.log("Unable to locate TypeSpec project (tspconfig.yaml) for modified files."); + return null; + } + if (tspProjectPaths.length > 1) { + console.log( + `Multiple TypeSpec projects found in PR: ${tspProjectPaths.join(", ")}. Create release plan manually using aka.ms/azsdk/releaseplan-dashboard.`, + ); + return null; + } + + const tspProjectPath = tspProjectPaths[0]; + const versionResult = detectApiVersions( + specFiles.map((f) => f.filename), + tspProjectPath, + workspace, + ); + + if (versionResult.apiVersions.length === 0) { + throw new Error("No API version found in modified files or TypeSpec project path/content."); + } + + return { + tspProjectPath, + apiVersion: versionResult.apiVersions[0], + isPreview: versionResult.isPreview, + }; +} + +/** + * Identifies TypeSpec project info from a commit SHA. + * Attempts to resolve an associated PR first; if not found, inspects commit file changes directly. + * @returns TypeSpec project info and optional associated PR number + */ +export async function getTypeSpecProjectInfoFromCommit(params: { + commitSha: string; + owner: string; + repo: string; + workspace: string; + octokit: OctokitLike; +}): Promise { + const { commitSha, owner, repo, workspace, octokit } = params; + + const associatedPrNumber = await getAssociatedPrNumber({ + commitSha, + owner, + repo, + octokit, + }); + console.log(`Associated PR number for commit ${commitSha}: ${associatedPrNumber ?? "none"}`); + if (associatedPrNumber) { + const hasNewApiVersionLabel = await checkNewApiVersionLabel({ + octokit, + owner, + repo, + prNumber: associatedPrNumber, + }); + + const projectInfo = await getTypeSpecProjectInfoFromPr({ + prNumber: associatedPrNumber, + owner, + repo, + workspace, + octokit, + }); + return { + projectInfo, + prNumber: associatedPrNumber, + hasNewApiVersionLabel, + }; + } + + const allFiles = await getCommitChangedFiles({ + octokit, + owner, + repo, + commitSha, + }); + + const specFiles = allFiles.filter((f) => f.filename.startsWith("specification/")); + if (specFiles.length === 0) { + return { projectInfo: null, hasNewApiVersionLabel: false }; + } + + const tspProjectPaths = collectTypeSpecProjectPaths(specFiles, workspace); + if (tspProjectPaths.length === 0) { + console.log("Unable to locate TypeSpec project (tspconfig.yaml) for modified files."); + return { projectInfo: null, hasNewApiVersionLabel: false }; + } + + if (tspProjectPaths.length > 1) { + console.log( + `Multiple TypeSpec projects found in commit: ${tspProjectPaths.join(", ")}. Create release plan manually using aka.ms/azsdk/releaseplan-dashboard.`, + ); + return { projectInfo: null, hasNewApiVersionLabel: false }; + } + + const tspProjectPath = tspProjectPaths[0]; + const versionResult = detectApiVersions( + specFiles.map((f) => f.filename), + tspProjectPath, + workspace, + ); + + if (versionResult.apiVersions.length === 0) { + throw new Error("No API version found in modified files or TypeSpec project path/content."); + } + + return { + projectInfo: { + tspProjectPath, + apiVersion: versionResult.apiVersions[0], + isPreview: versionResult.isPreview, + }, + hasNewApiVersionLabel: false, + }; +} + +export async function checkNewApiVersionLabel(params: { + octokit: OctokitLike; + owner: string; + repo: string; + prNumber: number; +}): Promise { + const { octokit, owner, repo, prNumber } = params; + + const pullRequest = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + return pullRequest.data.labels?.some((label) => label.name === "new-api-version") ?? false; +} + +/** + * Collects TypeSpec project paths from changed specification files. + * Prioritizes files with tspconfig.yaml changes, falls back to directory traversal if none found. + * @param specFiles Array of changed specification files + * @param workspace Absolute path to workspace root + * @returns Array of unique TypeSpec project paths + */ +export function collectTypeSpecProjectPaths( + specFiles: PullRequestChangedFile[], + workspace: string, +): string[] { + const projectPaths = new Set(); + + const changedTspConfigs = specFiles.filter( + (f) => f.filename.endsWith("/tspconfig.yaml") && f.status !== "removed", + ); + + if (changedTspConfigs.length > 0) { + for (const file of changedTspConfigs) { + projectPaths.add(posix.dirname(file.filename)); + } + } else { + for (const file of specFiles) { + const tspProject = findTspConfigDir(file.filename, workspace); + if (tspProject) { + projectPaths.add(tspProject); + } + } + } + + return [...projectPaths]; +} + +/** + * Fetch all changed files for a pull request through GitHub API. + * Uses pagination to retrieve all files (100 per page). + * @param params Object containing Octokit instance, repository details, and PR number + * @param params.octokit Octokit instance for GitHub API + * @param params.owner Repository owner + * @param params.repo Repository name + * @param params.prNumber Pull request number + * @returns Array of all changed files in the PR with filename and status + */ +export async function getPrChangedFiles(params: { + octokit: OctokitLike; + owner: string; + repo: string; + prNumber: number; +}): Promise { + const { octokit, owner, repo, prNumber } = params; + const allFiles: PullRequestChangedFile[] = []; + + for (let page = 1; ; page++) { + const response = await octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + page, + }); + + const data = response.data; + if (data.length === 0) { + break; + } + + for (const file of data) { + if (file.filename) { + allFiles.push({ filename: file.filename, status: file.status }); + } + } + + if (data.length < 100) { + break; + } + } + + return allFiles; +} + +/** + * Fetch changed files for a commit. + */ +export async function getCommitChangedFiles(params: { + octokit: OctokitLike; + owner: string; + repo: string; + commitSha: string; +}): Promise { + const { octokit, owner, repo, commitSha } = params; + if (!octokit.rest.repos?.getCommit) { + throw new Error("Octokit repos.getCommit is not available."); + } + + const response = await octokit.rest.repos.getCommit({ + owner, + repo, + ref: commitSha, + }); + + const files = response.data.files ?? []; + return files + .filter((file) => Boolean(file.filename)) + .map((file) => ({ filename: file.filename, status: file.status })); +} + +/** + * Resolve PR number associated with a commit SHA. + */ +export async function getAssociatedPrNumber(params: { + octokit: OctokitLike; + owner: string; + repo: string; + commitSha: string; +}): Promise { + const { octokit, owner, repo, commitSha } = params; + if (!octokit.rest.repos?.listPullRequestsAssociatedWithCommit) { + throw new Error("Octokit repos.listPullRequestsAssociatedWithCommit is not available."); + } + + const response = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: commitSha, + }); + + return response.data[0]?.number; +} + +/** + * Walk up from a file path to locate nearest TypeSpec project root with tspconfig.yaml. + * Starting from the directory of the given file, searches parent directories until + * a tspconfig.yaml is found or the specification root is reached. + * @param relativeFilePath Relative path to a file within specification directory + * @param workspace Absolute path to workspace root + * @returns Relative path to TypeSpec project directory, or null if not found + */ +export function findTspConfigDir(relativeFilePath: string, workspace: string): string | null { + let dir = posix.dirname(relativeFilePath); + + while (dir && dir !== "." && dir.startsWith("specification")) { + const tspConfigAbsolute = join(workspace, dir, "tspconfig.yaml"); + if (existsSync(tspConfigAbsolute)) { + return dir; + } + + const parent = posix.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return null; +} + +/** + * Detect API versions from changed file paths or from TypeSpec project content. + * Searches in order: changed file paths, project directory tree, main.tsp content. + * @param changedFiles Array of changed file paths + * @param tspProjectPath Relative path to TypeSpec project + * @param workspace Absolute path to workspace root + * @returns Object containing sorted API versions (latest first) and preview flag + */ +export function detectApiVersions( + changedFiles: string[], + tspProjectPath: string, + workspace: string, +): { apiVersions: string[]; isPreview: boolean } { + const apiVersionPattern = /(\d{4}-\d{2}-\d{2}(?:-preview)?)/g; + const versions = new Set(); + let isPreview = false; + + for (const file of changedFiles) { + const matches = file.match(apiVersionPattern); + if (matches) { + for (const version of matches) { + versions.add(version); + if (version.endsWith("-preview")) { + isPreview = true; + } + } + } + } + + if (versions.size === 0) { + for (const version of collectApiVersionsFromMainTsp(tspProjectPath, workspace)) { + versions.add(version); + if (version.endsWith("-preview")) { + isPreview = true; + } + } + } + + const apiVersions = [...versions].sort(compareApiVersionsDesc); + return { apiVersions, isPreview }; +} + +/** + * Collects API versions from main.tsp file content. + * Searches the TypeSpec project's main.tsp file for version patterns. + * @param tspProjectPath Relative path to TypeSpec project + * @param workspace Absolute path to workspace root + * @returns Array of API versions found in main.tsp content + */ +function collectApiVersionsFromMainTsp(tspProjectPath: string, workspace: string): string[] { + const mainFile = join(workspace, tspProjectPath, "main.tsp"); + if (!existsSync(mainFile)) { + return []; + } + + const content = readFileSync(mainFile, "utf8"); + const matches = content.match(/(\d{4}-\d{2}-\d{2}(?:-preview)?)/g); + if (!matches) { + return []; + } + + return [...new Set(matches)]; +} + +/** + * Compare API versions in descending order (latest first). + * Sorts by year, month, day, then prioritizes GA versions over preview for same date. + * @param a First API version string (e.g., "2025-06-01" or "2025-06-01-preview") + * @param b Second API version string + * @returns Negative if a > b, positive if b > a, zero if equal + */ +export function compareApiVersionsDesc(a: string, b: string): number { + const pa = parseApiVersion(a); + const pb = parseApiVersion(b); + + if (pa.year !== pb.year) { + return pb.year - pa.year; + } + if (pa.month !== pb.month) { + return pb.month - pa.month; + } + if (pa.day !== pb.day) { + return pb.day - pa.day; + } + if (pa.isPreview !== pb.isPreview) { + return pa.isPreview ? 1 : -1; + } + + return b.localeCompare(a); +} + +/** + * Parse API version string. + * Extracts year, month, day, and preview flag from a version string like "2025-06-01" or "2025-06-01-preview". + * @param version API version string in format YYYY-MM-DD or YYYY-MM-DD-preview + * @returns Parsed version components (year, month, day, isPreview), or zeros if invalid format + */ +export function parseApiVersion(version: string): { + year: number; + month: number; + day: number; + isPreview: boolean; +} { + const match = /^(\d{4})-(\d{2})-(\d{2})(-preview)?$/.exec(version); + if (!match) { + return { year: 0, month: 0, day: 0, isPreview: false }; + } + + return { + year: Number.parseInt(match[1], 10), + month: Number.parseInt(match[2], 10), + day: Number.parseInt(match[3], 10), + isPreview: Boolean(match[4]), + }; +} + +/** + * Create an Octokit instance for GitHub API access. + * Uses the explicit token first, then GITHUB_TOKEN/GH_TOKEN, then falls back to `gh auth token`. + * @param token GitHub authentication token (optional) + * @returns Octokit instance configured with the available credentials + */ +export function createOctokit(token: string | undefined): OctokitLike { + const auth = token ?? getGitHubAuthToken(); + + return new Octokit({ + auth, + }); +} + +function getGitHubAuthToken(): string | undefined { + const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (envToken) { + return envToken; + } + + try { + return execSync("gh auth token", { encoding: "utf8" }).trim() || undefined; + } catch { + return undefined; + } +} diff --git a/eng/tools/release-plan/test/pr-comment.test.ts b/eng/tools/release-plan/test/pr-comment.test.ts new file mode 100644 index 000000000000..4e9f21d782e9 --- /dev/null +++ b/eng/tools/release-plan/test/pr-comment.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildReleaseplanCommentBody, postReleasePlanComment } from "../src/pr-comment.ts"; + +type CreateCommentParams = { + owner: string; + repo: string; + issue_number: number; + body: string; +}; + +describe("PR comment creation", () => { + it("builds markdown comment body with all fields", () => { + const body = buildReleaseplanCommentBody({ + planId: "123", + planLink: "https://example.com/plan/123", + apiVersion: "2025-06-01-preview", + tspProjectPath: "specification/foo/Contoso.Service", + }); + + expect(body).toContain("### ✅ Release Plan Created"); + expect(body).toContain("[View Release Plan](https://example.com/plan/123)"); + expect(body).toContain("Release Plan ID"); + expect(body).toContain("123"); + expect(body).toContain("2025-06-01-preview"); + expect(body).toContain("specification/foo/Contoso.Service"); + }); + + it("builds comment body without link", () => { + const body = buildReleaseplanCommentBody({ + planId: "456", + apiVersion: "2025-05-01", + tspProjectPath: "specification/bar/Contoso.Bar", + }); + + expect(body).toContain("### ✅ Release Plan Created"); + expect(body).not.toContain("[View Release Plan]"); + expect(body).toContain("456"); + expect(body).toContain("2025-05-01"); + }); + + it("builds comment body without plan ID", () => { + const body = buildReleaseplanCommentBody({ + planLink: "https://example.com/plan/789", + apiVersion: "2025-04-01-preview", + tspProjectPath: "specification/baz/Contoso.Baz", + }); + + expect(body).toContain("### ✅ Release Plan Created"); + expect(body).toContain("[View Release Plan](https://example.com/plan/789)"); + expect(body).not.toContain("Release Plan ID"); + expect(body).toContain("2025-04-01-preview"); + }); + + it("posts comment successfully on PR", async () => { + const createComment = vi.fn().mockResolvedValueOnce(undefined); + + await postReleasePlanComment({ + octokit: { + rest: { + issues: { + createComment, + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + prNumber: 42, + planId: "999", + planLink: "https://example.com/plan/999", + apiVersion: "2025-06-01", + tspProjectPath: "specification/test/Test.Service", + }); + + expect(createComment).toHaveBeenCalledOnce(); + const firstCall = createComment.mock.calls[0] as [CreateCommentParams] | undefined; + if (!firstCall) { + throw new Error("Expected createComment to have one call."); + } + const [call] = firstCall; + expect(call.owner).toBe("Azure"); + expect(call.repo).toBe("azure-rest-api-specs"); + expect(call.issue_number).toBe(42); + expect(call.body).toContain("Release Plan Created"); + }); + + it("throws error when posting comment fails", async () => { + const createComment = vi.fn().mockRejectedValueOnce(new Error("Network error")); + + await expect( + postReleasePlanComment({ + octokit: { + rest: { + issues: { + createComment, + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + prNumber: 42, + apiVersion: "2025-06-01", + tspProjectPath: "specification/test/Test.Service", + }), + ).rejects.toThrow("Failed to post release plan comment on PR #42"); + }); + + it("posts comment without plan link or ID", async () => { + const createComment = vi.fn().mockResolvedValueOnce(undefined); + + await postReleasePlanComment({ + octokit: { + rest: { + issues: { + createComment, + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + prNumber: 123, + apiVersion: "2025-07-01-preview", + tspProjectPath: "specification/minimal/Minimal.Service", + }); + + expect(createComment).toHaveBeenCalledOnce(); + const firstCall = createComment.mock.calls[0] as [CreateCommentParams] | undefined; + if (!firstCall) { + throw new Error("Expected createComment to have one call."); + } + const [call] = firstCall; + expect(call.body).toContain("2025-07-01-preview"); + expect(call.body).toContain("specification/minimal/Minimal.Service"); + }); +}); diff --git a/eng/tools/release-plan/test/release-plan.test.ts b/eng/tools/release-plan/test/release-plan.test.ts new file mode 100644 index 000000000000..2f9c8feb0cad --- /dev/null +++ b/eng/tools/release-plan/test/release-plan.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureReleasePlan, getApiReleaseType, getSdkReleaseType } from "../src/release-plan.ts"; +import type { AzsdkRunner } from "../src/types.ts"; + +describe("release type helpers", () => { + it("sets Private Preview for private specs repo", () => { + expect(getApiReleaseType(true, "azure-rest-api-specs-pr")).toBe("Private Preview"); + expect(getApiReleaseType(false, "azure-rest-api-specs-pr")).toBe("Private Preview"); + }); + + it("sets Public Preview or GA for public repo", () => { + expect(getApiReleaseType(true, "azure-rest-api-specs")).toBe("Public Preview"); + expect(getApiReleaseType(false, "azure-rest-api-specs")).toBe("GA"); + }); + + it("sets SDK release type", () => { + expect(getSdkReleaseType(true)).toBe("preview"); + expect(getSdkReleaseType(false)).toBe("stable"); + }); +}); + +describe("ensureReleasePlan", () => { + function createRunner( + outputs: Record, + ): AzsdkRunner { + return (args: string[]) => { + const key = args.join(" "); + const found = outputs[key]; + if (!found) { + return { exitCode: 1, stdout: "", stderr: `Unexpected command: ${key}` }; + } + + return { + exitCode: found.code, + stdout: found.out, + stderr: found.err || "", + }; + }; + } + + const baseContext = { + prUrl: "https://github.com/Azure/azure-rest-api-specs/pull/123", + tspProjectPath: "specification/foo/Contoso.Service", + apiReleaseType: "Public Preview" as const, + sdkReleaseType: "preview" as const, + targetMonth: "July 2026", + apiVersion: "2026-06-01-preview", + testReleasePlan: false, + }; + + it("returns existing release plan when found by PR", () => { + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: JSON.stringify({ id: 100, apiVersion: "2026-06-01-preview" }), + }, + }); + + const result = ensureReleasePlan(baseContext, runner); + expect(result.outcome).toBe("existing_by_pr"); + expect(result.releasePlan).toEqual({ id: 100, apiVersion: "2026-06-01-preview" }); + }); + + it("returns existing release plan when found by path after PR lookup misses", () => { + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: JSON.stringify({ id: 101, apiVersion: "2026-06-01-preview" }), + }, + }); + + const result = ensureReleasePlan(baseContext, runner); + expect(result.outcome).toBe("existing_by_path"); + expect(result.releasePlan).toEqual({ id: 101, apiVersion: "2026-06-01-preview" }); + }); + + it("creates release plan when no existing plan is found", () => { + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: "null", + }, + "release-plan create --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --sdk-type preview --release-month July 2026 --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --force false --test-release false --output json": + { + code: 0, + out: JSON.stringify({ id: 999, release_plan_link: "https://example.test/999" }), + }, + }); + + const result = ensureReleasePlan(baseContext, runner); + expect(result.outcome).toBe("created"); + expect(result.releasePlan).toEqual({ id: 999, release_plan_link: "https://example.test/999" }); + }); + + it("passes test-release true when enabled", () => { + const testReleaseContext = { ...baseContext, testReleasePlan: true }; + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: "null", + }, + "release-plan create --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --sdk-type preview --release-month July 2026 --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --force false --test-release true --output json": + { + code: 0, + out: JSON.stringify({ id: 1000, release_plan_link: "https://example.test/1000" }), + }, + }); + + const result = ensureReleasePlan(testReleaseContext, runner); + expect(result.outcome).toBe("created"); + expect(result.releasePlan).toEqual({ + id: 1000, + release_plan_link: "https://example.test/1000", + }); + }); + + it("handles azsdk command failure on get-by-PR", () => { + const runner = vi.fn().mockReturnValue({ + exitCode: 1, + stdout: "", + stderr: "azsdk command not found", + }); + + expect(() => ensureReleasePlan(baseContext, runner)).toThrow(); + }); + + it("handles malformed JSON response from get-by-PR", () => { + const runner = vi.fn().mockReturnValue({ + exitCode: 0, + stdout: "{ invalid json", + stderr: "", + }); + + expect(() => ensureReleasePlan(baseContext, runner)).toThrow(); + }); + + it("handles malformed JSON response from create", () => { + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: "null", + }, + "release-plan create --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --sdk-type preview --release-month July 2026 --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --force false --test-release false --output json": + { + code: 0, + out: "{ broken json", + }, + }); + + expect(() => ensureReleasePlan(baseContext, runner)).toThrow(); + }); + + it("handles create command failure", () => { + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: "null", + }, + "release-plan create --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --sdk-type preview --release-month July 2026 --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --force false --test-release false --output json": + { + code: 1, + out: "", + err: "Cannot create release plan", + }, + }); + + expect(() => ensureReleasePlan(baseContext, runner)).toThrow(); + }); + + it("correctly handles december to january month wrap", () => { + const decContext = { ...baseContext, targetMonth: "December 2025" }; + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: "null", + }, + "release-plan create --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --sdk-type preview --release-month December 2025 --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --force false --test-release false --output json": + { + code: 0, + out: JSON.stringify({ id: 500 }), + }, + }); + + const result = ensureReleasePlan(decContext, runner); + expect(result.outcome).toBe("created"); + }); + + it("uses relative tsp path for create command", () => { + let capturedArgs: string[] = []; + const runner = (args: string[]) => { + capturedArgs = args; + if (args.includes("get")) { + return { exitCode: 0, stdout: "null", stderr: "" }; + } + return { exitCode: 0, stdout: JSON.stringify({ id: 42 }), stderr: "" }; + }; + + ensureReleasePlan(baseContext, runner); + const typespecPathArg = capturedArgs.indexOf("--typespec-path"); + expect(capturedArgs[typespecPathArg + 1]).toBe("specification/foo/Contoso.Service"); + }); + + it("returns not_found in get-only mode when no release plan exists", () => { + const runner = createRunner({ + "release-plan get --pull-request https://github.com/Azure/azure-rest-api-specs/pull/123 --output json": + { + code: 0, + out: "null", + }, + "release-plan get --typespec-path specification/foo/Contoso.Service --api-release-type Public Preview --output json": + { + code: 0, + out: "null", + }, + }); + + const result = ensureReleasePlan(baseContext, runner, false); + expect(result.outcome).toBe("not_found"); + expect(result.releasePlan).toBeNull(); + }); +}); diff --git a/eng/tools/release-plan/test/typespec-project.test.ts b/eng/tools/release-plan/test/typespec-project.test.ts new file mode 100644 index 000000000000..6d40c6fc9438 --- /dev/null +++ b/eng/tools/release-plan/test/typespec-project.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it, vi } from "vitest"; +import { + compareApiVersionsDesc, + createOctokit, + detectApiVersions, + findTspConfigDir, + getAssociatedPrNumber, + getCommitChangedFiles, + getPrChangedFiles, + getTypeSpecProjectInfoFromCommit, + getTypeSpecProjectInfoFromPr, + parseApiVersion, +} from "../src/typespec-project.ts"; + +describe("version helpers", () => { + it("sorts API versions descending with GA preferred over preview on same date", () => { + const input = ["2025-06-01-preview", "2025-06-01", "2026-01-01-preview"]; + const sorted = [...input].sort(compareApiVersionsDesc); + expect(sorted).toEqual(["2026-01-01-preview", "2025-06-01", "2025-06-01-preview"]); + }); + + it("parses valid API version", () => { + expect(parseApiVersion("2025-06-01-preview")).toEqual({ + year: 2025, + month: 6, + day: 1, + isPreview: true, + }); + }); + + it("returns zeros for invalid API version", () => { + expect(parseApiVersion("foo")).toEqual({ year: 0, month: 0, day: 0, isPreview: false }); + }); +}); + +describe("TypeSpec path discovery", () => { + it("finds nearest tspconfig.yaml directory", () => { + const workspace = process.cwd(); + const result = findTspConfigDir( + "specification/service/resource-manager/Microsoft.Sample/main.tsp", + workspace, + ); + expect(result === null || result.startsWith("specification/")).toBeTruthy(); + }); + + it("detects API versions from changed files", () => { + const result = detectApiVersions( + [ + "specification/foo/preview/2025-06-01-preview/main.tsp", + "specification/foo/stable/2025-05-01/foo.json", + ], + "specification/foo", + process.cwd(), + ); + + expect(result.apiVersions[0]).toBe("2025-06-01-preview"); + expect(result.isPreview).toBe(true); + }); +}); + +describe("GitHub PR file listing", () => { + it("handles paginated files response", async () => { + const listFiles = vi + .fn() + .mockResolvedValueOnce({ + data: [{ filename: "specification/a/main.tsp", status: "modified" }], + }) + .mockResolvedValueOnce({ data: [] }); + + const get = vi.fn(); + + const files = await getPrChangedFiles({ + octokit: { + rest: { + pulls: { + get, + listFiles, + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + prNumber: 1, + }); + + expect(files).toHaveLength(1); + expect(files[0].filename).toBe("specification/a/main.tsp"); + expect(listFiles).toHaveBeenCalledTimes(1); + }); + + it("creates Octokit instances with default settings", () => { + // Stub token so getGitHubAuthToken() never falls back to `execSync("gh auth token")` in CI. + vi.stubEnv("GITHUB_TOKEN", "test-token"); + try { + const ghCom = createOctokit(undefined); + const ghe = createOctokit("token"); + expect(ghCom).toBeDefined(); + expect(ghe).toBeDefined(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("returns null when no specification files are modified", async () => { + const get = vi.fn().mockResolvedValueOnce({ data: { labels: [{ name: "new-api-version" }] } }); + const listFiles = vi.fn().mockResolvedValueOnce({ data: [] }); + + const result = await getTypeSpecProjectInfoFromPr({ + prNumber: 42, + owner: "Azure", + repo: "azure-rest-api-specs", + workspace: process.cwd(), + octokit: { + rest: { + pulls: { + get, + listFiles, + }, + }, + }, + }); + + expect(result).toBeNull(); + }); +}); + +describe("TypeSpec project detection edge cases", () => { + it("still detects project when PR lacks new-api-version label", async () => { + const get = vi.fn().mockResolvedValueOnce({ data: { labels: [] } }); + const listFiles = vi + .fn() + .mockResolvedValueOnce({ + data: [ + { filename: "specification/foo/tspconfig.yaml", status: "modified" }, + { filename: "specification/foo/2025-08-01/main.tsp", status: "modified" }, + ], + }) + .mockResolvedValueOnce({ data: [] }); + + const result = await getTypeSpecProjectInfoFromPr({ + prNumber: 42, + owner: "Azure", + repo: "azure-rest-api-specs", + workspace: process.cwd(), + octokit: { + rest: { + pulls: { + get, + listFiles, + }, + }, + }, + }); + + expect(result).not.toBeNull(); + expect(result?.tspProjectPath).toBe("specification/foo"); + expect(result?.apiVersion).toBe("2025-08-01"); + expect(listFiles).toHaveBeenCalled(); + }); + + it("returns null when PR has multiple tsp projects", async () => { + const get = vi.fn().mockResolvedValueOnce({ data: { labels: [{ name: "new-api-version" }] } }); + const listFiles = vi + .fn() + .mockResolvedValueOnce({ + data: [ + { filename: "specification/proj1/tspconfig.yaml", status: "modified" }, + { filename: "specification/proj2/tspconfig.yaml", status: "modified" }, + ], + }) + .mockResolvedValueOnce({ data: [] }); + + const result = await getTypeSpecProjectInfoFromPr({ + prNumber: 42, + owner: "Azure", + repo: "azure-rest-api-specs", + workspace: process.cwd(), + octokit: { + rest: { + pulls: { + get, + listFiles, + }, + }, + }, + }); + + expect(result).toBeNull(); + }); + + it("handles paginated file responses across multiple pages", async () => { + const get = vi.fn().mockResolvedValueOnce({ data: { labels: [{ name: "new-api-version" }] } }); + const listFiles = vi + .fn() + .mockResolvedValueOnce({ + data: Array.from({ length: 100 }, (_, i) => ({ + filename: `specification/service/${i < 50 ? "old" : "new"}/file.txt`, + status: "modified", + })), + }) + .mockResolvedValueOnce({ + data: [{ filename: "specification/service/tspconfig.yaml", status: "modified" }], + }) + .mockResolvedValueOnce({ data: [] }); + + const files = await getPrChangedFiles({ + octokit: { + rest: { + pulls: { + get, + listFiles, + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + prNumber: 42, + }); + + expect(listFiles).toHaveBeenCalledTimes(2); + expect(files.length).toBe(101); + }); + + it("detects API versions with various format patterns", () => { + const result = detectApiVersions( + [ + "specification/foo/stable/2025-05-01/main.tsp", + "specification/foo/preview/2025-06-01-preview/models.tsp", + "specification/foo/2026-01-01/readme.md", + ], + "specification/foo", + process.cwd(), + ); + + expect(result.apiVersions).toContain("2025-05-01"); + expect(result.apiVersions).toContain("2025-06-01-preview"); + expect(result.apiVersions).toContain("2026-01-01"); + expect(result.isPreview).toBe(true); + }); + + it("returns null for invalid API version format", () => { + const result = parseApiVersion("not-a-date"); + expect(result).toEqual({ year: 0, month: 0, day: 0, isPreview: false }); + }); + + it("parses API version with preview suffix correctly", () => { + const result = parseApiVersion("2025-12-25-preview"); + expect(result).toEqual({ year: 2025, month: 12, day: 25, isPreview: true }); + }); + + it("resolves associated PR from commit SHA", async () => { + const listPullRequestsAssociatedWithCommit = vi.fn().mockResolvedValueOnce({ + data: [{ number: 99 }], + }); + + const result = await getAssociatedPrNumber({ + octokit: { + rest: { + pulls: { + get: vi.fn(), + listFiles: vi.fn(), + }, + repos: { + listPullRequestsAssociatedWithCommit, + getCommit: vi.fn(), + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + commitSha: "abc123", + }); + + expect(result).toBe(99); + }); + + it("gets changed files from commit SHA", async () => { + const getCommit = vi.fn().mockResolvedValueOnce({ + data: { + files: [ + { filename: "specification/foo/tspconfig.yaml", status: "modified" }, + { filename: "specification/foo/2026-01-01/main.tsp", status: "modified" }, + ], + }, + }); + + const files = await getCommitChangedFiles({ + octokit: { + rest: { + pulls: { + get: vi.fn(), + listFiles: vi.fn(), + }, + repos: { + listPullRequestsAssociatedWithCommit: vi.fn(), + getCommit, + }, + }, + }, + owner: "Azure", + repo: "azure-rest-api-specs", + commitSha: "def456", + }); + + expect(getCommit).toHaveBeenCalledOnce(); + expect(files).toHaveLength(2); + expect(files[0].filename).toBe("specification/foo/tspconfig.yaml"); + }); + + it("uses associated PR path when commit maps to a PR", async () => { + const listPullRequestsAssociatedWithCommit = vi.fn().mockResolvedValueOnce({ + data: [{ number: 123 }], + }); + const get = vi.fn().mockResolvedValueOnce({ data: { labels: [{ name: "new-api-version" }] } }); + const listFiles = vi + .fn() + .mockResolvedValueOnce({ + data: [ + { filename: "specification/foo/tspconfig.yaml", status: "modified" }, + { filename: "specification/foo/2026-01-01-preview/main.tsp", status: "modified" }, + ], + }) + .mockResolvedValueOnce({ data: [] }); + + const result = await getTypeSpecProjectInfoFromCommit({ + commitSha: "abc999", + owner: "Azure", + repo: "azure-rest-api-specs", + workspace: process.cwd(), + octokit: { + rest: { + pulls: { + get, + listFiles, + }, + repos: { + listPullRequestsAssociatedWithCommit, + getCommit: vi.fn(), + }, + }, + }, + }); + + expect(result.prNumber).toBe(123); + expect(result.hasNewApiVersionLabel).toBe(true); + expect(result.projectInfo?.tspProjectPath).toBe("specification/foo"); + expect(result.projectInfo?.apiVersion).toBe("2026-01-01-preview"); + }); + + it("falls back to commit file analysis when no PR is associated", async () => { + const listPullRequestsAssociatedWithCommit = vi.fn().mockResolvedValueOnce({ data: [] }); + const getCommit = vi.fn().mockResolvedValueOnce({ + data: { + files: [ + { filename: "specification/bar/tspconfig.yaml", status: "modified" }, + { filename: "specification/bar/2025-09-01/main.tsp", status: "modified" }, + ], + }, + }); + + const result = await getTypeSpecProjectInfoFromCommit({ + commitSha: "zzz111", + owner: "Azure", + repo: "azure-rest-api-specs", + workspace: process.cwd(), + octokit: { + rest: { + pulls: { + get: vi.fn(), + listFiles: vi.fn(), + }, + repos: { + listPullRequestsAssociatedWithCommit, + getCommit, + }, + }, + }, + }); + + expect(result.prNumber).toBeUndefined(); + expect(result.hasNewApiVersionLabel).toBe(false); + expect(result.projectInfo?.tspProjectPath).toBe("specification/bar"); + expect(result.projectInfo?.apiVersion).toBe("2025-09-01"); + }); +}); diff --git a/eng/tools/release-plan/tsconfig.json b/eng/tools/release-plan/tsconfig.json new file mode 100644 index 000000000000..4e6e92aa746f --- /dev/null +++ b/eng/tools/release-plan/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"], +} diff --git a/eng/tools/release-plan/vitest.config.js b/eng/tools/release-plan/vitest.config.js new file mode 100644 index 000000000000..cc1eea51f064 --- /dev/null +++ b/eng/tools/release-plan/vitest.config.js @@ -0,0 +1,5 @@ +// @ts-check + +import { baseConfig } from "../vitest.base.config.js"; + +export default baseConfig; diff --git a/eng/tools/tsconfig.json b/eng/tools/tsconfig.json index c66a2e8839a3..998856416be3 100644 --- a/eng/tools/tsconfig.json +++ b/eng/tools/tsconfig.json @@ -20,6 +20,8 @@ "oav-runner/test/**/*.ts", "openapi-diff-runner/src/**/*.ts", "openapi-diff-runner/test/**/*.ts", + "release-plan/src/**/*.ts", + "release-plan/test/**/*.ts", "sdk-suppressions/src/**/*.ts", "sdk-suppressions/test/**/*.ts", "spec-gen-sdk-runner/src/**/*.ts", diff --git a/package-lock.json b/package-lock.json index 723d797d5f87..a17f0509f611 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "@azure-tools/lint-diff": "file:lint-diff", "@azure-tools/oav-runner": "file:oav-runner", "@azure-tools/openapi-diff-runner": "file:openapi-diff-runner", + "@azure-tools/release-plan": "file:release-plan", "@azure-tools/sdk-suppressions": "file:sdk-suppressions", "@azure-tools/spec-gen-sdk-runner": "file:spec-gen-sdk-runner", "@azure-tools/summarize-impact": "file:summarize-impact", @@ -401,6 +402,31 @@ "node": ">=24.14.1" } }, + "eng/tools/release-plan": { + "name": "@azure-tools/release-plan", + "dev": true, + "dependencies": { + "@octokit/rest": "^22.0.0" + }, + "bin": { + "release-plan": "cmd/release-plan.js" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.1.0", + "cross-env": "^10.1.0", + "eslint": "^10.0.0", + "prettier": "3.8.3", + "prettier-plugin-organize-imports": "^4.2.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vitest": "^4.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "eng/tools/sdk-suppressions": { "name": "@azure-tools/sdk-suppressions", "version": "1.0.0", @@ -982,6 +1008,10 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/@azure-tools/release-plan": { + "resolved": "eng/tools/release-plan", + "link": true + }, "node_modules/@azure-tools/sdk-suppressions": { "resolved": "eng/tools/sdk-suppressions", "link": true