diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eec965b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Run tests + run: npx vitest run + + - name: Build + run: npm run build + + - name: Check dist is up to date + run: | + git diff --exit-code dist/ || (echo "Error: dist/ is out of date. Run 'npm run build' and commit the result." && exit 1) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..611afa8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + release: + types: [published] + +jobs: + tag-aliases: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Move major and minor version tags + if: ${{ !github.event.release.prerelease }} + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${{ github.event.release.tag_name }}" + + # Only move tags if this is the latest release for the repo + LATEST=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName) + if [ "$LATEST" != "$VERSION" ]; then + echo "Skipping: $VERSION is not the latest release (latest is $LATEST)" + exit 0 + fi + + STRIPPED="$(echo "$VERSION" | sed 's/^v//')" + PARTS="$(echo "$STRIPPED" | tr '.' '\n' | wc -l)" + MAJOR="v$(echo "$STRIPPED" | cut -d. -f1)" + + # Always move the major tag (e.g. v3) + echo "Updating major tag: $MAJOR" + git tag -f "$MAJOR" + git push origin "refs/tags/$MAJOR" --force + + # Move the minor tag only if version has 3+ parts (e.g. v3.1.0 → v3.1) + if [ "$PARTS" -ge 3 ]; then + MINOR="v$(echo "$STRIPPED" | cut -d. -f1).$(echo "$STRIPPED" | cut -d. -f2)" + echo "Updating minor tag: $MINOR" + git tag -f "$MINOR" + git push origin "refs/tags/$MINOR" --force + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..724bf7a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## v4 + +### Added + +- **PR deduplication for `update_from_source` mode.** When `auto_merge` is `false`, the action now checks for an existing open PR on the branch before creating a new one. Subsequent runs push new commits to the existing PR instead of creating duplicates. +- **Push fallback with rebase retry.** If a regular push is rejected (e.g., branch has diverged), the action attempts `git pull --rebase` and retries. If rebase fails due to merge conflicts, a detailed comment is left on the PR with the full git error output and resolution steps, and the action fails. +- **Default branch name.** The `branch` input now defaults to `fern/sync-openapi`. Customers can still override it. +- **Release workflow.** Publishing a GitHub Release (e.g., `v4.1.0`) automatically force-updates the major (`v4`) and minor (`v4.1`) version tags so consumers on `@v4` or `@v4.1` stay up to date. +- **CI workflow.** Runs lint (Biome), tests (Vitest), build, and `dist/` verification on every PR and push to main. +- **E2E test script** (`e2e/run-e2e.ts`) that validates happy path (PR reuse) and conflict path (error comment) against a real test repo. +- **Biome.js** for linting and formatting, replacing ESLint. Configured with 4-space indentation, double quotes, recommended lint rules, and import sorting. +- **12 unit tests** covering PR creation, PR reuse, no-op on no changes, push without `--force`, rebase retry, PR comment on conflict, error path separation, rebase abort error handling, `setFailed` on all failure paths, and auto-merge bypass. + +### Changed + +- **Node runtime bumped to `node20`** (from deprecated `node16`) in `action.yml`. +- **Vitest 4** replaces Jest as the test framework. +- **CI actions updated** to `actions/checkout@v6` and `actions/setup-node@v6` with Node 20. +- **Non-null assertions replaced** with runtime guards in `syncChanges` for safer error handling. +- **Caught errors are now logged** via `core.debug()` instead of being silently ignored, aiding debugging when `ACTIONS_STEP_DEBUG` is enabled. +- **`--force` removed from `git push`** in the `updateFromSourceSpec` path so commits accumulate naturally. + +### Fixed + +- **Duplicate PRs.** The `updateFromSourceSpec` function previously created a new PR on every run that detected changes, even if an open PR already existed for the same branch. +- **Error messages in PR comments.** Multi-line git error output now uses fenced code blocks instead of inline code spans, fixing broken Markdown rendering on GitHub. +- **Error path separation.** `pushWithFallback` now correctly distinguishes between "rebase failed" (merge conflicts) and "rebase succeeded but push failed" (push rejection), providing accurate diagnostic labels in PR comments. + +## v3 + +### Added + +- Removed `addTimestamp` from branch names. +- Small cleanup and reformatting. + +### Changed + +- Updated `glob` and `js-yaml` dependencies. + +## v2.1 + +### Fixed + +- Fixed branch logic and `--force` tag handling. +- Removed date from branch names. + +## v2 + +### Added + +- Option to run `fern api upgrade`. +- Branch name formatting. +- Upstream remote support. + +### Changed + +- Updated actions and token handling. + +## v1 + +### Added + +- Directory and file mapping with `from`/`to` fields. +- Glob-based `exclude` patterns via `minimatch`. +- Better error messages for fetch failures. + +## v0 + +- Initial release with basic OpenAPI spec syncing between repositories. diff --git a/README.md b/README.md index 4ac121e..5836231 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A GitHub Action to [sync OpenAPI specifications with your Fern setup](/learn/api ### Case 1: Sync specs from public URL (recommended) -1. In your source repo, create a file named `sync-openapi.yml` in `.github/workflows/`. +1. In your repo, create a file named `sync-openapi.yml` in `.github/workflows/`. 2. Include the following contents in `sync-openapi.yml`: ```yaml @@ -30,15 +30,18 @@ jobs: with: token: ${{ secrets.OPENAPI_SYNC_TOKEN }} - name: Update API with Fern - uses: fern-api/sync-openapi@v2 + uses: fern-api/sync-openapi@v4 with: update_from_source: true token: ${{ secrets.OPENAPI_SYNC_TOKEN }} - branch: 'update-api' auto_merge: false # you MUST use auto_merge: true with branch: main ``` +> **PR deduplication**: When `auto_merge` is `false`, the action creates a single PR on the `branch` (default: `fern/sync-openapi`) and accumulates commits on subsequent runs. If the source spec hasn't changed, the action is a no-op. This prevents duplicate PRs from piling up. +> +> **Branch divergence handling**: If the PR branch has diverged (e.g., someone manually rebased or edited it), the action will attempt to rebase automatically. If rebase fails due to merge conflicts, a comment is left on the PR with detailed error output and resolution steps. + ### Case 2: Sync files/folders between repositories 1. In your source repo, create a file named `sync-openapi.yml` in `.github/workflows/`. @@ -60,7 +63,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Sync OpenAPI spec to target repo - uses: fern-api/sync-openapi@v2 + uses: fern-api/sync-openapi@v4 with: repository: / token: ${{ secrets. }} @@ -83,12 +86,12 @@ jobs: | Input | Description | Required | Default | Case | |--------------------|---------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------------------|---------| -| `token` | GitHub token for authentication | No | `${{ github.token }}` | 1, 2 | -| `branch` | Branch to push to in the target repository | Yes | - | 1, 2 | +| `token` | GitHub token for authentication | Yes | - | 1, 2 | +| `branch` | Branch name to create or update. **Must be a stable name** (e.g., `fern/sync-openapi`) — do not use dynamic/timestamped names, or PR deduplication will not work. | No | `fern/sync-openapi` | 1, 2 | | `auto_merge` | If `true`, pushes directly to the branch; if `false`, creates a PR from the branch onto `main` | No | `false` | 1, 2 | | `sources` | Array of mappings with `from`, `to`, and optional `exclude` fields | Yes | - | 2 | | `repository` | Target repository in format `org/repo` | Yes | - | 2 | -| `update_from_source`| If `true`, syncs from the source spec files rather than using existing intermediate formats | No | `false` | 1 | +| `update_from_source`| If `true`, runs `fern api update` on the current repository instead of syncing files between repos | No | `false` | 1 | **Note: you must set `auto_merge: true` when using `branch: main`** @@ -107,3 +110,16 @@ The GitHub token used for this action must have: 3. Name your token (i.e. `OPENAPI_SYNC_TOKEN`) and paste in the PAT token generated above 4. Replace `` in the example YAML configuration with your token name. +## Releasing + +This project uses GitHub Releases to publish new versions. When a release is published, a workflow automatically updates the major and minor version tags so consumers stay up to date. + +For example, publishing release `v4.1.0` will: +- Force-update the `v4` tag (so `@v4` users get the update) +- Force-update the `v4.1` tag (so `@v4.1` users get the update) + +To release: +1. Go to [Releases → Draft a new release](../../releases/new) +2. Create a new tag (e.g., `v4.0.1`) following [semver](https://semver.org/) +3. Click **Publish release** + diff --git a/action.yml b/action.yml index acfd151..7b1be51 100644 --- a/action.yml +++ b/action.yml @@ -9,7 +9,8 @@ inputs: required: true branch: description: 'Branch name to create or update' - required: true + required: false + default: 'fern/sync-openapi' sources: description: 'JSON or YAML array of mappings (from source to destination) (only used when update_from_source is false)' required: false @@ -22,8 +23,8 @@ inputs: required: false default: 'false' runs: - using: 'node16' + using: 'node20' main: 'dist/index.js' branding: icon: 'refresh-cw' - color: 'green' \ No newline at end of file + color: 'green' diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..597e721 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/dist/index.js b/dist/index.js index cc99c26..bbf265a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -39311,19 +39311,19 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.run = run; +const fs = __importStar(__nccwpck_require__(7561)); +const path = __importStar(__nccwpck_require__(9411)); const core = __importStar(__nccwpck_require__(2186)); -const github = __importStar(__nccwpck_require__(5438)); const exec = __importStar(__nccwpck_require__(1514)); +const github = __importStar(__nccwpck_require__(5438)); const io = __importStar(__nccwpck_require__(7436)); -const fs = __importStar(__nccwpck_require__(7147)); -const path = __importStar(__nccwpck_require__(1017)); -const yaml = __importStar(__nccwpck_require__(1917)); const glob = __importStar(__nccwpck_require__(8211)); +const yaml = __importStar(__nccwpck_require__(1917)); const minimatch_1 = __nccwpck_require__(4501); async function run() { try { const token = core.getInput("token") || process.env.GITHUB_TOKEN; - const branch = core.getInput("branch", { required: true }); + const branch = core.getInput("branch") || "fern/sync-openapi"; const autoMerge = core.getBooleanInput("auto_merge"); const updateFromSource = core.getBooleanInput("update_from_source"); if (!token) { @@ -39368,9 +39368,18 @@ async function updateFromSourceSpec(token, branch, autoMerge) { await exec.exec("git", ["add", "."], { silent: true }); await exec.exec("git", ["commit", "-m", "Update API specifications with fern api update"], { silent: true }); core.info(`Pushing changes to branch: ${branch}`); - await exec.exec("git", ["push", "--force", "--verbose", "origin", branch], { silent: false }); + const pushSucceeded = await pushWithFallback(branch, owner, repo, octokit); + if (!pushSucceeded) { + return; + } if (!autoMerge) { - await createPR(octokit, owner, repo, branch, github.context.ref.replace("refs/heads/", ""), true); + const existingPRNumber = await prExists(owner, repo, branch, octokit); + if (existingPRNumber) { + core.info(`PR #${existingPRNumber} already exists for branch '${branch}'. New commit has been pushed to the existing PR.`); + } + else { + await createPR(octokit, owner, repo, branch, github.context.ref.replace("refs/heads/", ""), true); + } } else { core.info(`Changes pushed directly to branch '${branch}' because auto-merge is enabled.`); @@ -39392,6 +39401,7 @@ async function updateTargetSpec(token, branch, autoMerge) { fileMapping = JSON.parse(fileMappingInput); } catch (jsonError) { + core.debug(`JSON parse also failed: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`); throw new Error(`Failed to parse 'sources' input as either YAML or JSON. Please check the format. Error: ${yamlError.message}`); } } @@ -39421,6 +39431,7 @@ async function runFernApiUpdate() { core.info("Fern CLI is already installed"); } catch (error) { + core.debug(`Fern CLI check failed: ${error instanceof Error ? error.message : String(error)}`); core.info("Fern CLI not found. Installing Fern CLI..."); await exec.exec("npm", ["install", "-g", "fern-api"]); } @@ -39460,6 +39471,7 @@ async function cloneRepository(options) { await exec.exec("git", ["clone", repoUrl, repoDir]); } catch (error) { + core.debug(`Clone error: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Failed to clone repository. Please ensure your token has 'repo' scope and you have write access to ${options.repository}.`); } process.chdir(repoDir); @@ -39471,6 +39483,12 @@ async function cloneRepository(options) { ]); } async function syncChanges(options) { + if (!options.token) { + throw new Error("GitHub token is required for syncing changes."); + } + if (!options.branch) { + throw new Error("Branch name is required for syncing changes."); + } const octokit = github.getOctokit(options.token); const [owner, repo] = options.repository.split("/"); try { @@ -39519,7 +39537,7 @@ async function branchExists(owner, repo, branchName, octokit) { }); return true; } - catch (error) { + catch (_error) { return false; } } @@ -39601,7 +39619,7 @@ async function hasDifferenceWithRemote(branchName) { const diff = await exec.getExecOutput("git", ["diff", `HEAD`, `origin/${branchName}`], { silent: true }); return !!diff.stdout.trim(); } - catch (error) { + catch (_error) { core.info(`Could not fetch remote branch, assuming this is the first push to new branch.`); return true; } @@ -39627,6 +39645,90 @@ async function pushChanges(branchName, options) { throw new Error(`Failed to push changes to the repository: ${error instanceof Error ? error.message : "Unknown error"}`); } } +// Push with fallback: try regular push, then rebase + push, then comment on PR with error details +async function pushWithFallback(branchName, owner, repo, octokit) { + // Try regular push first + try { + await exec.exec("git", ["push", "--verbose", "origin", branchName], { + silent: false, + }); + return true; + } + catch { + core.info(`Regular push to '${branchName}' failed. Attempting to rebase on remote branch.`); + } + // Try pull --rebase then push + let errorMsg = null; + let errorLabel = "Rebase error"; + let abortErrorMsg = null; + let rebaseFailed = false; + const rebaseResult = await exec.getExecOutput("git", ["pull", "--rebase", "origin", branchName], { ignoreReturnCode: true }); + if (rebaseResult.exitCode === 0) { + const pushResult = await exec.getExecOutput("git", ["push", "--verbose", "origin", branchName], { ignoreReturnCode: true }); + if (pushResult.exitCode === 0) { + core.info(`Successfully pushed to '${branchName}' after rebasing on remote changes.`); + return true; + } + // Push after rebase failed — no rebase in progress, don't abort + errorLabel = "Push error"; + errorMsg = + [pushResult.stderr.trim(), pushResult.stdout.trim()] + .filter(Boolean) + .join("\n") || + `git push failed with exit code ${pushResult.exitCode}`; + core.info(`Push after rebase failed: ${errorMsg}`); + } + else { + // Rebase itself failed (merge conflicts) + rebaseFailed = true; + errorLabel = "Rebase error"; + errorMsg = + [rebaseResult.stderr.trim(), rebaseResult.stdout.trim()] + .filter(Boolean) + .join("\n") || + `git pull --rebase failed with exit code ${rebaseResult.exitCode}`; + core.info(`Rebase failed (likely due to merge conflicts). Aborting rebase.`); + // Abort the rebase so the working tree is clean + const abortResult = await exec.getExecOutput("git", ["rebase", "--abort"], { ignoreReturnCode: true }); + if (abortResult.exitCode !== 0) { + abortErrorMsg = + [abortResult.stderr.trim(), abortResult.stdout.trim()] + .filter(Boolean) + .join("\n") || + `rebase --abort failed with exit code ${abortResult.exitCode}`; + core.info(`rebase --abort failed: ${abortErrorMsg}. The working tree may be in an unexpected state.`); + } + } + // Last resort: leave a comment on the existing PR + const existingPRNumber = await prExists(owner, repo, branchName, octokit); + if (existingPRNumber) { + core.info(`Could not push to '${branchName}'. Leaving a comment on PR #${existingPRNumber}.`); + const failureReason = rebaseFailed + ? "merge conflicts" + : "a push rejection after successful rebase"; + let commentBody = `⚠️ **Sync failed**: The latest \`fern api update\` detected changes, but they could not be pushed to this branch due to ${failureReason}.\n\n` + + `**To resolve**, either:\n` + + `- Merge or close this PR so the next run creates a fresh one, or\n` + + `- Manually rebase this branch on \`${github.context.ref.replace("refs/heads/", "")}\` and re-run the workflow.`; + if (errorMsg) { + commentBody += `\n\n**${errorLabel}:**\n\`\`\`\n${errorMsg}\n\`\`\``; + } + if (abortErrorMsg) { + commentBody += `\n\n**Rebase abort error:**\n\`\`\`\n${abortErrorMsg}\n\`\`\`\n— the working tree may be in an unexpected state.`; + } + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: existingPRNumber, + body: commentBody, + }); + core.setFailed(`Failed to push changes to '${branchName}' due to ${failureReason}. A comment has been left on PR #${existingPRNumber}.`); + } + else { + core.setFailed(`Failed to push changes to '${branchName}' and no existing PR was found to comment on.`); + } + return false; +} // Check if a PR exists for a branch async function prExists(owner, repo, branchName, octokit) { const prs = await octokit.rest.pulls.list({ @@ -39651,10 +39753,10 @@ async function updatePR(octokit, owner, repo, prNumber) { async function createPR(octokit, owner, repo, branchName, targetBranch, isFromFern) { core.info(`Creating new PR from ${branchName} to ${targetBranch}`); const date = new Date().toISOString().replace(/[:.]/g, "-"); - let prTitle = isFromFern + const prTitle = isFromFern ? `chore: Update API specifications with fern api update (${date})` : `chore: Update OpenAPI specifications (${date})`; - let prBody = isFromFern + const prBody = isFromFern ? "Update API specifications by running fern api update." : "Update OpenAPI specifications based on changes in the source repository."; const prResponse = await octokit.rest.pulls.create({ @@ -39668,7 +39770,12 @@ async function createPR(octokit, owner, repo, branchName, targetBranch, isFromFe core.info(`Pull request created: ${prResponse.data.html_url}`); return prResponse; } -run(); +// Auto-invoke only when running as the action entry point (not when imported in tests). +// Vitest sets VITEST=true automatically; NODE_ENV is not used because a customer's +// workflow could set NODE_ENV=test and silently prevent run() from executing. +if (!process.env.VITEST) { + run(); +} /***/ }), diff --git a/e2e/run-e2e.ts b/e2e/run-e2e.ts new file mode 100644 index 0000000..2ee72ad --- /dev/null +++ b/e2e/run-e2e.ts @@ -0,0 +1,542 @@ +/** + * E2E test script for sync-openapi action. + * + * Prerequisites: + * - GITHUB_TOKEN env var with repo scope for fern-demo/test-openapi-sync + * - The test repo must have the sync-openapi workflow and fern config set up + * - The test repo must be PUBLIC (so raw.githubusercontent.com URLs work) + * + * Usage: + * GITHUB_TOKEN= npx tsx e2e/run-e2e.ts + * + * What it tests: + * 1. Happy path: trigger workflow twice (updating source spec each time), + * verify only 1 PR exists and commits accumulate + * 2. Conflict path: push a conflicting commit to PR branch, trigger again, + * verify action fails and leaves a comment with error details + * + * Important: fern api update only writes when the origin content has changed, + * so we must update source-spec/openapi.json before each workflow trigger. + */ + +const OWNER = "fern-demo"; +const REPO = "test-openapi-sync"; +const BRANCH = "update-api"; +const WORKFLOW_FILE = "sync-openapi.yml"; +const SOURCE_SPEC_PATH = "source-spec/openapi.json"; +const FERN_SPEC_PATH = "fern/openapi/openapi.json"; + +let specVersion = 100; + +interface GitHubPR { + number: number; + head: { sha: string; ref: string }; + html_url: string; +} + +interface GitHubComment { + id: number; + body: string; +} + +interface WorkflowRun { + id: number; + status: string; + conclusion: string | null; + html_url: string; +} + +async function githubApi( + path: string, + options: { + method?: string; + body?: unknown; + accept?: string; + } = {}, +): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN environment variable is required"); + } + + const url = `https://api.github.com${path}`; + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: options.accept || "application/vnd.github.v3+json", + "Content-Type": "application/json", + }; + + const resp = await fetch(url, { + method: options.method || "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error( + `GitHub API ${resp.status} ${resp.statusText}: ${text}`, + ); + } + + // 204 No Content + if (resp.status === 204) return null; + + return resp.json(); +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --- Source spec helpers --- + +/** + * Get the current SHA of a file on main (needed for the update API). + */ +async function getFileSha(path: string): Promise { + const data = await githubApi( + `/repos/${OWNER}/${REPO}/contents/${path}?ref=main`, + ); + return data.sha; +} + +/** + * Update source-spec/openapi.json on main to a new version. + * This simulates the upstream API spec changing, which is what + * triggers fern api update to actually write new content. + */ +async function updateSourceSpec(): Promise { + specVersion++; + const spec = { + openapi: "3.0.3", + info: { + title: "Test Sync API", + version: `${specVersion}.0.0`, + description: `Auto-generated test spec version ${specVersion}`, + }, + paths: { + "/health": { + get: { + operationId: "getHealth", + summary: "Health check endpoint", + responses: { + "200": { description: "Service is healthy" }, + }, + }, + }, + "/status": { + get: { + operationId: "getStatus", + summary: `Status v${specVersion}`, + responses: { + "200": { + description: `Returns status v${specVersion}`, + }, + }, + }, + }, + }, + }; + + const content = Buffer.from(`${JSON.stringify(spec, null, 2)}\n`).toString( + "base64", + ); + const sha = await getFileSha(SOURCE_SPEC_PATH); + + await githubApi(`/repos/${OWNER}/${REPO}/contents/${SOURCE_SPEC_PATH}`, { + method: "PUT", + body: { + message: `test: update source spec to v${specVersion}`, + content, + sha, + }, + }); + console.log(` Updated source spec to v${specVersion}`); + + // raw.githubusercontent.com can have a short cache delay; wait briefly + await sleep(3000); +} + +/** + * Reset fern/openapi/openapi.json on a given branch to a placeholder so that + * fern api update always produces a diff on the next run. + */ +async function resetFernSpec(branch: string = "main"): Promise { + const content = Buffer.from("{}").toString("base64"); + const data = await githubApi( + `/repos/${OWNER}/${REPO}/contents/${FERN_SPEC_PATH}?ref=${branch}`, + ); + + await githubApi(`/repos/${OWNER}/${REPO}/contents/${FERN_SPEC_PATH}`, { + method: "PUT", + body: { + message: "test: reset fern spec to placeholder", + content, + sha: data.sha, + branch, + }, + }); + console.log(` Reset fern/openapi/openapi.json to {} on ${branch}`); +} + +// --- Cleanup helpers --- + +async function closeOpenPRs(): Promise { + const prs: GitHubPR[] = await githubApi( + `/repos/${OWNER}/${REPO}/pulls?head=${OWNER}:${BRANCH}&state=open`, + ); + for (const pr of prs) { + console.log(` Closing PR #${pr.number}`); + await githubApi(`/repos/${OWNER}/${REPO}/pulls/${pr.number}`, { + method: "PATCH", + body: { state: "closed" }, + }); + } +} + +async function deleteBranch(): Promise { + try { + await githubApi(`/repos/${OWNER}/${REPO}/git/refs/heads/${BRANCH}`, { + method: "DELETE", + }); + console.log(` Deleted branch ${BRANCH}`); + } catch (e: any) { + if (e.message.includes("422") || e.message.includes("404")) { + console.log(` Branch ${BRANCH} does not exist (ok)`); + } else { + throw e; + } + } +} + +async function cleanup(): Promise { + console.log("Cleaning up..."); + await closeOpenPRs(); + await deleteBranch(); +} + +// --- Workflow helpers --- + +async function triggerWorkflow(): Promise { + await githubApi( + `/repos/${OWNER}/${REPO}/actions/workflows/${WORKFLOW_FILE}/dispatches`, + { + method: "POST", + body: { ref: "main" }, + }, + ); + console.log(" Triggered workflow dispatch"); +} + +async function waitForWorkflowRun( + afterDate: Date, + timeoutMs: number = 300000, +): Promise { + const deadline = Date.now() + timeoutMs; + + // Wait a few seconds for the run to appear + await sleep(5000); + + while (Date.now() < deadline) { + const data = await githubApi( + `/repos/${OWNER}/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?per_page=5`, + ); + const runs: WorkflowRun[] = data.workflow_runs; + + // Find a run that started after our trigger + const run = runs.find((r: any) => new Date(r.created_at) > afterDate); + + if (run) { + if (run.status === "completed") { + console.log( + ` Workflow run ${run.id} completed: ${run.conclusion}`, + ); + return run; + } + console.log(` Workflow run ${run.id} status: ${run.status}...`); + } else { + console.log(" Waiting for workflow run to appear..."); + } + + await sleep(10000); + } + + throw new Error("Timed out waiting for workflow run"); +} + +// --- Assertion helpers --- + +async function getOpenPRs(): Promise { + return githubApi( + `/repos/${OWNER}/${REPO}/pulls?head=${OWNER}:${BRANCH}&state=open`, + ); +} + +async function getPRCommitCount(prNumber: number): Promise { + const commits = await githubApi( + `/repos/${OWNER}/${REPO}/pulls/${prNumber}/commits`, + ); + return commits.length; +} + +async function getPRComments(prNumber: number): Promise { + return githubApi(`/repos/${OWNER}/${REPO}/issues/${prNumber}/comments`); +} + +async function pushConflictingCommit(branchSha: string): Promise { + // Create a blob with conflicting content + const blob = await githubApi(`/repos/${OWNER}/${REPO}/git/blobs`, { + method: "POST", + body: { + content: JSON.stringify( + { + openapi: "3.0.3", + info: { + title: "CONFLICTING CHANGE", + version: "999.0.0", + }, + paths: {}, + }, + null, + 2, + ), + encoding: "utf-8", + }, + }); + + // Get the current tree + const commit = await githubApi( + `/repos/${OWNER}/${REPO}/git/commits/${branchSha}`, + ); + + // Create a new tree with the conflicting file + const tree = await githubApi(`/repos/${OWNER}/${REPO}/git/trees`, { + method: "POST", + body: { + base_tree: commit.tree.sha, + tree: [ + { + path: "fern/openapi/openapi.json", + mode: "100644", + type: "blob", + sha: blob.sha, + }, + ], + }, + }); + + // Create a commit + const newCommit = await githubApi(`/repos/${OWNER}/${REPO}/git/commits`, { + method: "POST", + body: { + message: "Conflicting change to force merge conflict", + tree: tree.sha, + parents: [branchSha], + }, + }); + + // Update the branch ref + await githubApi(`/repos/${OWNER}/${REPO}/git/refs/heads/${BRANCH}`, { + method: "PATCH", + body: { sha: newCommit.sha, force: true }, + }); + + console.log(` Pushed conflicting commit ${newCommit.sha} to ${BRANCH}`); +} + +// --- Test cases --- + +async function testHappyPath(): Promise { + console.log("\n=== TEST: Happy Path ==="); + + console.log( + "Step 1: Update source spec and trigger workflow (first run - should create PR)", + ); + await updateSourceSpec(); + + const before1 = new Date(); + await triggerWorkflow(); + const run1 = await waitForWorkflowRun(before1); + + if (run1.conclusion !== "success") { + throw new Error( + `Expected first run to succeed, got: ${run1.conclusion} (${run1.html_url})`, + ); + } + + const prs1 = await getOpenPRs(); + if (prs1.length !== 1) { + throw new Error( + `Expected 1 open PR after first run, got ${prs1.length}`, + ); + } + const prNumber = prs1[0].number; + const commits1 = await getPRCommitCount(prNumber); + console.log(` PR #${prNumber} created with ${commits1} commit(s)`); + + console.log( + "\nStep 2: Reset fern spec on PR branch and trigger workflow (should reuse existing PR)", + ); + // Instead of changing the source spec (which is subject to raw.githubusercontent.com CDN cache), + // we reset fern/openapi/openapi.json on the update-api branch to {} so that fern api update + // will write the (cached) origin content and produce a git diff. + await resetFernSpec(BRANCH); + + const before2 = new Date(); + await triggerWorkflow(); + const run2 = await waitForWorkflowRun(before2); + + if (run2.conclusion !== "success") { + throw new Error( + `Expected second run to succeed, got: ${run2.conclusion} (${run2.html_url})`, + ); + } + + const prs2 = await getOpenPRs(); + if (prs2.length !== 1) { + throw new Error( + `Expected still 1 open PR after second run, got ${prs2.length}`, + ); + } + + if (prs2[0].number !== prNumber) { + throw new Error( + `Expected same PR #${prNumber}, got PR #${prs2[0].number}`, + ); + } + + const commits2 = await getPRCommitCount(prNumber); + if (commits2 <= commits1) { + throw new Error( + `Expected more commits after second run (had ${commits1}, now ${commits2})`, + ); + } + + console.log( + ` Still 1 PR (#${prNumber}), commits: ${commits1} → ${commits2}`, + ); + console.log(" PASS: Happy path\n"); +} + +async function testConflictPath(): Promise { + console.log("\n=== TEST: Conflict Path ==="); + + // Ensure we have a PR from happy path + const prs = await getOpenPRs(); + if (prs.length === 0) { + throw new Error("No open PR found - run happy path test first"); + } + const pr = prs[0]; + console.log(` Using existing PR #${pr.number}`); + + // Get the latest SHA of the PR branch for the conflicting commit + const branchData = await githubApi( + `/repos/${OWNER}/${REPO}/git/refs/heads/${BRANCH}`, + ); + const _currentSha = branchData.object.sha; + + console.log( + "Step 1: Reset fern spec on PR branch so fern api update will produce a diff", + ); + await resetFernSpec(BRANCH); + + console.log( + "Step 2: Trigger workflow and push conflicting commit during fern install window", + ); + // The workflow takes ~5-10s to install fern-api. During this window, + // we push a commit to origin/update-api that the action won't have locally, + // causing its subsequent push to fail (remote has commits the local doesn't). + const before = new Date(); + await triggerWorkflow(); + + // Wait for the workflow to start and pull the branch, then push a conflicting commit. + // The fern-api install takes ~5-10s, so we have a window. + console.log( + " Waiting 12s for workflow to pull branch before pushing conflict...", + ); + await sleep(12000); + + // Get the updated SHA (after resetFernSpec added a commit) + const updatedBranch = await githubApi( + `/repos/${OWNER}/${REPO}/git/refs/heads/${BRANCH}`, + ); + await pushConflictingCommit(updatedBranch.object.sha); + + const run = await waitForWorkflowRun(before); + + if (run.conclusion === "failure") { + console.log(` Workflow failed as expected (${run.html_url})`); + } else if (run.conclusion === "success") { + // The rebase might have succeeded (no actual content conflict), + // or the timing was off (commit arrived after push). + console.log( + ` Workflow succeeded - rebase may have resolved the conflict, or timing was off.`, + ); + console.log(` Check: ${run.html_url}`); + } + + console.log("Step 3: Check for conflict comment on PR"); + const comments = await getPRComments(pr.number); + const conflictComment = comments.find( + (c) => + c.body.includes("Sync failed") || c.body.includes("Rebase error"), + ); + + if (conflictComment) { + console.log(` Found conflict comment on PR #${pr.number}:`); + console.log(` ${conflictComment.body.substring(0, 200)}...`); + console.log(" PASS: Conflict path - error comment posted\n"); + } else if (run.conclusion === "failure") { + console.log( + ` Workflow failed but no conflict comment found. The push failure might have happened`, + ); + console.log( + ` before the PR comment step. Check logs: ${run.html_url}`, + ); + console.log(" PARTIAL PASS: Conflict detected but no comment\n"); + } else { + console.log( + ` No conflict detected (timing-dependent test). Workflow succeeded and no error comment.`, + ); + console.log( + ` This is acceptable - the conflict test relies on pushing during the fern install window.`, + ); + console.log( + ` The conflict handling logic is thoroughly covered by unit tests.`, + ); + console.log(" SKIP: Conflict path (timing missed)\n"); + } +} + +// --- Main --- + +async function main(): Promise { + console.log("sync-openapi E2E Tests"); + console.log(`Target repo: ${OWNER}/${REPO}`); + console.log(`Branch: ${BRANCH}\n`); + + try { + await cleanup(); + await resetFernSpec(); + await testHappyPath(); + await testConflictPath(); + await cleanup(); + + console.log("=== ALL TESTS PASSED ==="); + } catch (error) { + console.error("\n=== TEST FAILED ==="); + console.error(error); + + // Cleanup on failure too + try { + await cleanup(); + } catch { + // ignore cleanup errors + } + + process.exit(1); + } +} + +main(); diff --git a/package-lock.json b/package-lock.json index 342ccbd..7184a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,10 +18,12 @@ "minimatch": "^10.2.3" }, "devDependencies": { + "@biomejs/biome": "^2.4.5", "@types/js-yaml": "^4.0.5", - "@types/node": "^18.15.11", + "@types/node": "^20.0.0", "@vercel/ncc": "^0.36.1", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "vitest": "^4.0.18" } }, "node_modules/@actions/core": { @@ -74,6 +76,611 @@ "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", "license": "MIT" }, + "node_modules/@biomejs/biome": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.5.tgz", + "integrity": "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.5", + "@biomejs/cli-darwin-x64": "2.4.5", + "@biomejs/cli-linux-arm64": "2.4.5", + "@biomejs/cli-linux-arm64-musl": "2.4.5", + "@biomejs/cli-linux-x64": "2.4.5", + "@biomejs/cli-linux-x64-musl": "2.4.5", + "@biomejs/cli-win32-arm64": "2.4.5", + "@biomejs/cli-win32-x64": "2.4.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.5.tgz", + "integrity": "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.5.tgz", + "integrity": "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.5.tgz", + "integrity": "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.5.tgz", + "integrity": "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.5.tgz", + "integrity": "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.5.tgz", + "integrity": "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.5.tgz", + "integrity": "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.5.tgz", + "integrity": "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -91,6 +698,13 @@ "node": ">=12" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -249,6 +863,388 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -257,13 +1253,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@vercel/ncc": { @@ -276,6 +1272,117 @@ "ncc": "dist/ncc/cli.js" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -306,6 +1413,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -333,6 +1450,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -383,6 +1510,93 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -399,6 +1613,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -473,6 +1702,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -497,6 +1736,36 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -537,6 +1806,107 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -558,6 +1928,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -570,6 +1947,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -666,6 +2067,50 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -699,9 +2144,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -711,6 +2156,159 @@ "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", "license": "ISC" }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -726,6 +2324,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index 0193bcb..ad9ae90 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "scripts": { "build:sync": "ncc build src/sync.ts -o dist", "build": "npm run build:sync", - "test": "jest", - "lint": "eslint src/**/*.ts" + "test": "vitest run", + "lint": "biome check src/ e2e/", + "format": "biome check --write src/ e2e/" }, "keywords": [ "github", @@ -26,10 +27,12 @@ "minimatch": "^10.2.3" }, "devDependencies": { + "@biomejs/biome": "^2.4.5", "@types/js-yaml": "^4.0.5", - "@types/node": "^18.15.11", + "@types/node": "^20.0.0", "@vercel/ncc": "^0.36.1", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "vitest": "^4.0.18" }, "overrides": { "undici": "^6.23.0" diff --git a/src/sync.test.ts b/src/sync.test.ts new file mode 100644 index 0000000..a0e8895 --- /dev/null +++ b/src/sync.test.ts @@ -0,0 +1,590 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Use shared state objects so mocks across resetModules don't create circular refs +const state = { + infoCalls: [] as string[], + setFailedCalls: [] as string[], + execCalls: [] as [string, string[] | undefined, unknown][], // [cmd, args, opts] + getInputImpl: (_name: string): string => "", + getBooleanInputImpl: (_name: string): boolean => false, + execImpl: async (_cmd: string, _args?: string[]): Promise => 0, + getExecOutputImpl: async ( + _cmd: string, + _args?: string[], + ): Promise<{ + stdout: string; + stderr: string; + exitCode: number; + }> => ({ + stdout: "", + stderr: "", + exitCode: 0, + }), + mockOctokit: null as any, +}; + +// Shared mock state for octokit calls +let mockPullsList: ReturnType; +let mockPullsCreate: ReturnType; +let mockPullsUpdate: ReturnType; +let mockGitGetRef: ReturnType; +let mockIssuesCreateComment: ReturnType; + +// Factory mocks that delegate to shared state +vi.mock("@actions/core", () => ({ + getInput: vi.fn((...args: any[]) => state.getInputImpl(args[0])), + getBooleanInput: vi.fn((...args: any[]) => + state.getBooleanInputImpl(args[0]), + ), + info: vi.fn((msg: string) => { + state.infoCalls.push(msg); + }), + setFailed: vi.fn((msg: string) => { + state.setFailedCalls.push(msg); + }), + warning: vi.fn(), +})); + +vi.mock("@actions/github", () => ({ + getOctokit: vi.fn(() => state.mockOctokit), + context: { + repo: { owner: "test-owner", repo: "test-repo" }, + ref: "refs/heads/main", + }, +})); + +vi.mock("@actions/exec", () => ({ + exec: vi.fn( + async ( + cmd: string, + args?: string[], + opts?: unknown, + ): Promise => { + state.execCalls.push([cmd, args, opts]); + return state.execImpl(cmd, args); + }, + ), + getExecOutput: vi.fn( + async (cmd: string, args?: string[], opts?: unknown) => { + state.execCalls.push([cmd, args, opts]); + return state.getExecOutputImpl(cmd, args); + }, + ), +})); + +vi.mock("@actions/io", () => ({ + mkdirP: vi.fn(), +})); + +function setupMocks({ + hasChanges = true, + existingPRNumber = null as number | null, + branchExists = false, + autoMerge = false, +}: { + hasChanges?: boolean; + existingPRNumber?: number | null; + branchExists?: boolean; + autoMerge?: boolean; +} = {}) { + // Reset shared state + state.infoCalls = []; + state.setFailedCalls = []; + state.execCalls = []; + + // Setup input implementations + state.getInputImpl = (name: string): string => { + const inputs: Record = { + token: "fake-token", + branch: "update-api", + auto_merge: "false", + update_from_source: "true", + }; + return inputs[name] || ""; + }; + state.getBooleanInputImpl = (name: string): boolean => { + if (name === "auto_merge") return autoMerge; + if (name === "update_from_source") return true; + return false; + }; + + // Setup exec implementations + state.execImpl = async (_cmd: string, _args?: string[]) => 0; + state.getExecOutputImpl = async (_cmd: string, _args?: string[]) => ({ + stdout: hasChanges ? "M openapi/openapi.json\n" : "", + stderr: "", + exitCode: 0, + }); + + // Setup GitHub octokit mocks + mockPullsList = vi.fn().mockResolvedValue({ + data: existingPRNumber ? [{ number: existingPRNumber }] : [], + }); + mockPullsCreate = vi.fn().mockResolvedValue({ + data: { html_url: "https://github.com/test-owner/test-repo/pull/1" }, + }); + mockPullsUpdate = vi.fn().mockResolvedValue({}); + mockIssuesCreateComment = vi.fn().mockResolvedValue({}); + mockGitGetRef = branchExists + ? vi.fn().mockResolvedValue({}) + : vi.fn().mockRejectedValue(new Error("Not found")); + + state.mockOctokit = { + rest: { + pulls: { + list: mockPullsList, + create: mockPullsCreate, + update: mockPullsUpdate, + }, + git: { + getRef: mockGitGetRef, + }, + issues: { + createComment: mockIssuesCreateComment, + }, + }, + }; +} + +async function importAndRun() { + vi.resetModules(); + + const { run } = await import("./sync"); + await run(); +} + +beforeEach(() => { + vi.clearAllMocks(); + state.infoCalls = []; + state.setFailedCalls = []; + state.execCalls = []; +}); + +describe("updateFromSourceSpec", () => { + describe("when changes are detected and no existing PR", () => { + it("should create a new PR", async () => { + setupMocks({ hasChanges: true, existingPRNumber: null }); + await importAndRun(); + + expect(mockPullsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + head: "update-api", + base: "main", + }), + ); + }); + + it("should check for existing PRs before creating", async () => { + setupMocks({ hasChanges: true, existingPRNumber: null }); + await importAndRun(); + + expect(mockPullsList).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + head: "test-owner:update-api", + state: "open", + }), + ); + + expect(mockPullsCreate).toHaveBeenCalledTimes(1); + }); + }); + + describe("when changes are detected and an existing PR exists", () => { + it("should NOT create a new PR", async () => { + setupMocks({ hasChanges: true, existingPRNumber: 42 }); + await importAndRun(); + + expect(mockPullsList).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + head: "test-owner:update-api", + state: "open", + }), + ); + + expect(mockPullsCreate).not.toHaveBeenCalled(); + }); + + it("should log that the existing PR was reused", async () => { + setupMocks({ hasChanges: true, existingPRNumber: 42 }); + await importAndRun(); + + expect(state.infoCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("PR #42 already exists"), + ]), + ); + }); + }); + + describe("when no changes are detected", () => { + it("should not push or create a PR", async () => { + setupMocks({ hasChanges: false }); + await importAndRun(); + + expect(mockPullsCreate).not.toHaveBeenCalled(); + expect(state.infoCalls).toContain( + "No changes detected from fern api update. Skipping further actions.", + ); + }); + }); + + describe("git push behavior", () => { + it("should push without --force flag", async () => { + setupMocks({ hasChanges: true, existingPRNumber: null }); + await importAndRun(); + + const pushCall = state.execCalls.find( + ([cmd, args]) => + cmd === "git" && + Array.isArray(args) && + args.includes("push"), + ); + + expect(pushCall).toBeDefined(); + expect(pushCall?.[1]).not.toContain("--force"); + expect(pushCall?.[1]).toContain("--verbose"); + expect(pushCall?.[1]).toContain("origin"); + expect(pushCall?.[1]).toContain("update-api"); + }); + + it("should rebase and retry push when regular push fails", async () => { + setupMocks({ hasChanges: true, existingPRNumber: null }); + let pushAttempt = 0; + // First push via exec throws (regular push) + state.execImpl = async ( + cmd: string, + args?: string[], + ): Promise => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("push") + ) { + pushAttempt++; + if (pushAttempt === 1) { + throw new Error("rejected (non-fast-forward)"); + } + } + return 0; + }; + // Rebase + second push via getExecOutput succeed + state.getExecOutputImpl = async (cmd: string, args?: string[]) => { + // git status --porcelain returns changes + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("--porcelain") + ) { + return { + stdout: "M openapi/openapi.json\n", + stderr: "", + exitCode: 0, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + await importAndRun(); + + // Should have done a pull --rebase + const rebasePull = state.execCalls.find( + ([cmd, args]) => + cmd === "git" && + Array.isArray(args) && + args.includes("pull") && + args.includes("--rebase"), + ); + expect(rebasePull).toBeDefined(); + + // PR should still be created after successful retry + expect(mockPullsCreate).toHaveBeenCalledTimes(1); + }); + + it("should comment on PR when push and rebase both fail", async () => { + setupMocks({ hasChanges: true, existingPRNumber: 42 }); + // First push via exec throws + state.execImpl = async ( + cmd: string, + args?: string[], + ): Promise => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("push") + ) { + throw new Error("rejected (non-fast-forward)"); + } + return 0; + }; + // Rebase via getExecOutput fails with detailed error + state.getExecOutputImpl = async (cmd: string, args?: string[]) => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("--porcelain") + ) { + return { + stdout: "M openapi/openapi.json\n", + stderr: "", + exitCode: 0, + }; + } + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("pull") && + args.includes("--rebase") + ) { + return { + stdout: "CONFLICT (content): Merge conflict in fern/openapi/openapi.json", + stderr: "error: could not apply abc1234... Update API", + exitCode: 1, + }; + } + // rebase --abort succeeds + return { stdout: "", stderr: "", exitCode: 0 }; + }; + await importAndRun(); + + // Should NOT create a new PR + expect(mockPullsCreate).not.toHaveBeenCalled(); + + // Should leave a comment on the existing PR + expect(mockIssuesCreateComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + issue_number: 42, + }), + ); + + // Comment body should mention sync failed and include detailed error + const commentCall = mockIssuesCreateComment.mock.calls[0][0]; + expect(commentCall.body).toContain("Sync failed"); + expect(commentCall.body).toContain("merge conflicts"); + expect(commentCall.body).toContain("Rebase error:"); + expect(commentCall.body).toContain("```"); + expect(commentCall.body).toContain("CONFLICT (content)"); + expect(commentCall.body).toContain("could not apply"); + + // Action should still fail (not silently succeed) + expect(state.setFailedCalls).toEqual( + expect.arrayContaining([expect.stringContaining("conflicts")]), + ); + }); + + it("should label as 'Push error' when rebase succeeds but post-rebase push fails", async () => { + setupMocks({ hasChanges: true, existingPRNumber: 42 }); + // First push via exec throws + state.execImpl = async ( + cmd: string, + args?: string[], + ): Promise => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("push") + ) { + throw new Error("rejected (non-fast-forward)"); + } + return 0; + }; + // Rebase succeeds but post-rebase push fails + state.getExecOutputImpl = async (cmd: string, args?: string[]) => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("--porcelain") + ) { + return { + stdout: "M openapi/openapi.json\n", + stderr: "", + exitCode: 0, + }; + } + // rebase succeeds + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("pull") && + args.includes("--rebase") + ) { + return { stdout: "", stderr: "", exitCode: 0 }; + } + // post-rebase push fails + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("push") + ) { + return { + stdout: "", + stderr: "remote rejected", + exitCode: 1, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + await importAndRun(); + + const commentCall = mockIssuesCreateComment.mock.calls[0][0]; + // Should say "Push error", NOT "Rebase error" + expect(commentCall.body).toContain("Push error:"); + expect(commentCall.body).not.toContain("Rebase error:"); + // Should mention push rejection, not merge conflicts + expect(commentCall.body).toContain( + "push rejection after successful rebase", + ); + expect(commentCall.body).not.toContain("merge conflicts"); + // Should NOT have run rebase --abort (no rebase in progress) + const abortCall = state.execCalls.find( + ([cmd, args]) => + cmd === "git" && + Array.isArray(args) && + args.includes("rebase") && + args.includes("--abort"), + ); + expect(abortCall).toBeUndefined(); + }); + + it("should call setFailed when push fails and no existing PR to comment on", async () => { + setupMocks({ hasChanges: true, existingPRNumber: null }); + // First push via exec throws + state.execImpl = async ( + cmd: string, + args?: string[], + ): Promise => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("push") + ) { + throw new Error("rejected (non-fast-forward)"); + } + return 0; + }; + // Rebase via getExecOutput also fails + state.getExecOutputImpl = async (cmd: string, args?: string[]) => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("--porcelain") + ) { + return { + stdout: "M openapi/openapi.json\n", + stderr: "", + exitCode: 0, + }; + } + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("pull") && + args.includes("--rebase") + ) { + return { + stdout: "", + stderr: "merge conflict", + exitCode: 1, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + await importAndRun(); + + expect(mockPullsCreate).not.toHaveBeenCalled(); + expect(mockIssuesCreateComment).not.toHaveBeenCalled(); + expect(state.setFailedCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("Failed to push changes"), + ]), + ); + }); + + it("should include rebase abort error in PR comment when abort fails", async () => { + setupMocks({ hasChanges: true, existingPRNumber: 42 }); + // First push via exec throws + state.execImpl = async ( + cmd: string, + args?: string[], + ): Promise => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("push") + ) { + throw new Error("rejected (non-fast-forward)"); + } + return 0; + }; + // Rebase fails AND abort fails via getExecOutput + state.getExecOutputImpl = async (cmd: string, args?: string[]) => { + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("--porcelain") + ) { + return { + stdout: "M openapi/openapi.json\n", + stderr: "", + exitCode: 0, + }; + } + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("pull") && + args.includes("--rebase") + ) { + return { + stdout: "", + stderr: "merge conflict", + exitCode: 1, + }; + } + if ( + cmd === "git" && + Array.isArray(args) && + args.includes("rebase") && + args.includes("--abort") + ) { + return { + stdout: "", + stderr: "no rebase in progress", + exitCode: 1, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + await importAndRun(); + + const commentCall = mockIssuesCreateComment.mock.calls[0][0]; + expect(commentCall.body).toContain("Rebase error:"); + expect(commentCall.body).toContain("```"); + expect(commentCall.body).toContain("merge conflict"); + expect(commentCall.body).toContain("Rebase abort error:"); + expect(commentCall.body).toContain("no rebase in progress"); + }); + }); + + describe("when auto_merge is true", () => { + it("should not check for existing PRs or create new ones", async () => { + setupMocks({ hasChanges: true, autoMerge: true }); + await importAndRun(); + + expect(mockPullsList).not.toHaveBeenCalled(); + expect(mockPullsCreate).not.toHaveBeenCalled(); + + expect(state.infoCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("auto-merge is enabled"), + ]), + ); + }); + }); +}); diff --git a/src/sync.ts b/src/sync.ts index e419458..5e41444 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,11 +1,11 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import * as core from "@actions/core"; -import * as github from "@actions/github"; import * as exec from "@actions/exec"; +import * as github from "@actions/github"; import * as io from "@actions/io"; -import * as fs from "fs"; -import * as path from "path"; -import * as yaml from "js-yaml"; import * as glob from "glob"; +import * as yaml from "js-yaml"; import { minimatch } from "minimatch"; interface SourceMapping { @@ -26,7 +26,7 @@ interface SyncOptions { export async function run(): Promise { try { const token = core.getInput("token") || process.env.GITHUB_TOKEN; - const branch = core.getInput("branch", { required: true }); + const branch = core.getInput("branch") || "fern/sync-openapi"; const autoMerge = core.getBooleanInput("auto_merge"); const updateFromSource = core.getBooleanInput("update_from_source"); @@ -101,21 +101,39 @@ async function updateFromSourceSpec( core.info(`Pushing changes to branch: ${branch}`); - await exec.exec( - "git", - ["push", "--force", "--verbose", "origin", branch], - { silent: false }, + const pushSucceeded = await pushWithFallback( + branch, + owner, + repo, + octokit, ); + if (!pushSucceeded) { + return; + } + if (!autoMerge) { - await createPR( - octokit, + const existingPRNumber = await prExists( owner, repo, branch, - github.context.ref.replace("refs/heads/", ""), - true, + octokit, ); + + if (existingPRNumber) { + core.info( + `PR #${existingPRNumber} already exists for branch '${branch}'. New commit has been pushed to the existing PR.`, + ); + } else { + await createPR( + octokit, + owner, + repo, + branch, + github.context.ref.replace("refs/heads/", ""), + true, + ); + } } else { core.info( `Changes pushed directly to branch '${branch}' because auto-merge is enabled.`, @@ -143,6 +161,9 @@ async function updateTargetSpec( try { fileMapping = JSON.parse(fileMappingInput) as SourceMapping[]; } catch (jsonError) { + core.debug( + `JSON parse also failed: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`, + ); throw new Error( `Failed to parse 'sources' input as either YAML or JSON. Please check the format. Error: ${(yamlError as Error).message}`, ); @@ -181,6 +202,9 @@ async function runFernApiUpdate(): Promise { await exec.exec("fern", ["--version"], { silent: true }); core.info("Fern CLI is already installed"); } catch (error) { + core.debug( + `Fern CLI check failed: ${error instanceof Error ? error.message : String(error)}`, + ); core.info("Fern CLI not found. Installing Fern CLI..."); await exec.exec("npm", ["install", "-g", "fern-api"]); } @@ -233,6 +257,9 @@ async function cloneRepository(options: SyncOptions): Promise { try { await exec.exec("git", ["clone", repoUrl, repoDir]); } catch (error) { + core.debug( + `Clone error: ${error instanceof Error ? error.message : String(error)}`, + ); throw new Error( `Failed to clone repository. Please ensure your token has 'repo' scope and you have write access to ${options.repository}.`, ); @@ -248,11 +275,18 @@ async function cloneRepository(options: SyncOptions): Promise { } async function syncChanges(options: SyncOptions): Promise { - const octokit = github.getOctokit(options.token!); + if (!options.token) { + throw new Error("GitHub token is required for syncing changes."); + } + if (!options.branch) { + throw new Error("Branch name is required for syncing changes."); + } + + const octokit = github.getOctokit(options.token); const [owner, repo] = options.repository.split("/"); try { - const workingBranch = options.branch!; + const workingBranch = options.branch; if (options.autoMerge) { core.info( @@ -335,7 +369,7 @@ async function branchExists( ref: `heads/${branchName}`, }); return true; - } catch (error) { + } catch (_error) { return false; } } @@ -448,7 +482,7 @@ async function hasDifferenceWithRemote(branchName: string): Promise { ); return !!diff.stdout.trim(); - } catch (error) { + } catch (_error) { core.info( `Could not fetch remote branch, assuming this is the first push to new branch.`, ); @@ -483,6 +517,133 @@ async function pushChanges( } } +// Push with fallback: try regular push, then rebase + push, then comment on PR with error details +async function pushWithFallback( + branchName: string, + owner: string, + repo: string, + octokit: any, +): Promise { + // Try regular push first + try { + await exec.exec("git", ["push", "--verbose", "origin", branchName], { + silent: false, + }); + return true; + } catch { + core.info( + `Regular push to '${branchName}' failed. Attempting to rebase on remote branch.`, + ); + } + + // Try pull --rebase then push + let errorMsg: string | null = null; + let errorLabel: string = "Rebase error"; + let abortErrorMsg: string | null = null; + let rebaseFailed = false; + + const rebaseResult = await exec.getExecOutput( + "git", + ["pull", "--rebase", "origin", branchName], + { ignoreReturnCode: true }, + ); + + if (rebaseResult.exitCode === 0) { + const pushResult = await exec.getExecOutput( + "git", + ["push", "--verbose", "origin", branchName], + { ignoreReturnCode: true }, + ); + + if (pushResult.exitCode === 0) { + core.info( + `Successfully pushed to '${branchName}' after rebasing on remote changes.`, + ); + return true; + } + + // Push after rebase failed — no rebase in progress, don't abort + errorLabel = "Push error"; + errorMsg = + [pushResult.stderr.trim(), pushResult.stdout.trim()] + .filter(Boolean) + .join("\n") || + `git push failed with exit code ${pushResult.exitCode}`; + core.info(`Push after rebase failed: ${errorMsg}`); + } else { + // Rebase itself failed (merge conflicts) + rebaseFailed = true; + errorLabel = "Rebase error"; + errorMsg = + [rebaseResult.stderr.trim(), rebaseResult.stdout.trim()] + .filter(Boolean) + .join("\n") || + `git pull --rebase failed with exit code ${rebaseResult.exitCode}`; + + core.info( + `Rebase failed (likely due to merge conflicts). Aborting rebase.`, + ); + + // Abort the rebase so the working tree is clean + const abortResult = await exec.getExecOutput( + "git", + ["rebase", "--abort"], + { ignoreReturnCode: true }, + ); + if (abortResult.exitCode !== 0) { + abortErrorMsg = + [abortResult.stderr.trim(), abortResult.stdout.trim()] + .filter(Boolean) + .join("\n") || + `rebase --abort failed with exit code ${abortResult.exitCode}`; + core.info( + `rebase --abort failed: ${abortErrorMsg}. The working tree may be in an unexpected state.`, + ); + } + } + + // Last resort: leave a comment on the existing PR + const existingPRNumber = await prExists(owner, repo, branchName, octokit); + if (existingPRNumber) { + core.info( + `Could not push to '${branchName}'. Leaving a comment on PR #${existingPRNumber}.`, + ); + + const failureReason = rebaseFailed + ? "merge conflicts" + : "a push rejection after successful rebase"; + + let commentBody = + `⚠️ **Sync failed**: The latest \`fern api update\` detected changes, but they could not be pushed to this branch due to ${failureReason}.\n\n` + + `**To resolve**, either:\n` + + `- Merge or close this PR so the next run creates a fresh one, or\n` + + `- Manually rebase this branch on \`${github.context.ref.replace("refs/heads/", "")}\` and re-run the workflow.`; + + if (errorMsg) { + commentBody += `\n\n**${errorLabel}:**\n\`\`\`\n${errorMsg}\n\`\`\``; + } + if (abortErrorMsg) { + commentBody += `\n\n**Rebase abort error:**\n\`\`\`\n${abortErrorMsg}\n\`\`\`\n— the working tree may be in an unexpected state.`; + } + + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: existingPRNumber, + body: commentBody, + }); + core.setFailed( + `Failed to push changes to '${branchName}' due to ${failureReason}. A comment has been left on PR #${existingPRNumber}.`, + ); + } else { + core.setFailed( + `Failed to push changes to '${branchName}' and no existing PR was found to comment on.`, + ); + } + + return false; +} + // Check if a PR exists for a branch async function prExists( owner: string, @@ -529,11 +690,11 @@ async function createPR( core.info(`Creating new PR from ${branchName} to ${targetBranch}`); const date = new Date().toISOString().replace(/[:.]/g, "-"); - let prTitle = isFromFern + const prTitle = isFromFern ? `chore: Update API specifications with fern api update (${date})` : `chore: Update OpenAPI specifications (${date})`; - let prBody = isFromFern + const prBody = isFromFern ? "Update API specifications by running fern api update." : "Update OpenAPI specifications based on changes in the source repository."; @@ -550,4 +711,9 @@ async function createPR( return prResponse; } -run(); +// Auto-invoke only when running as the action entry point (not when imported in tests). +// Vitest sets VITEST=true automatically; NODE_ENV is not used because a customer's +// workflow could set NODE_ENV=test and silently prevent run() from executing. +if (!process.env.VITEST) { + run(); +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..08f84a8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +});