diff --git a/.github/scripts/example.ts b/.github/scripts/example.ts new file mode 100644 index 0000000..118e058 --- /dev/null +++ b/.github/scripts/example.ts @@ -0,0 +1,92 @@ +import { + pullRequestHasLabels, + getCurrentPullRequestNumber, + getRepoInfo, + sanitizeInput, + escapeMarkdown, + formatDate, + checkBranchExists, +} from "github-typescript-utils"; + +type Ctx = { + core: typeof import("@actions/core"); + github: ReturnType; + context: typeof import("@actions/github").context; + args: { + testMessage: string; + testLabels: string[]; + testBranch: string; + }; +}; + +/** + * E2E test script that exercises various utility functions + * to verify they can be imported and bundled correctly + */ +export default async function run({ core, github, context, args }: Ctx) { + core.info("🚀 Starting E2E test of github-typescript-utils"); + + try { + const ctx = { core, github, context }; + + // Test 1: Input sanitization + const sanitizedMessage = sanitizeInput(args.testMessage); + core.info( + `✅ Input sanitization: "${args.testMessage}" → "${sanitizedMessage}"` + ); + + // Test 2: Text formatting utilities + const escapedText = escapeMarkdown("**Bold** _italic_ `code`"); + const formattedDate = formatDate(new Date()); + core.info( + `✅ Text formatting: escaped="${escapedText}", date="${formattedDate}"` + ); + + // Test 3: Context utilities + const repoInfo = getRepoInfo(ctx); + core.info(`✅ Repo info: ${repoInfo.owner}/${repoInfo.repo}`); + + // Test 4: PR context (if available) + const prNumber = getCurrentPullRequestNumber(ctx); + if (prNumber) { + core.info(`✅ PR context: Found PR #${prNumber}`); + + // Test PR utilities + const hasLabels = await pullRequestHasLabels({ + ctx, + repo: repoInfo, + pullNumber: prNumber, + labels: args.testLabels, + }); + core.info( + `✅ PR label check: has labels [${args.testLabels.join( + ", " + )}] = ${hasLabels}` + ); + } else { + core.info("â„šī¸ No PR context available, skipping PR-specific tests"); + } + + // Test 5: Branch utilities + const branchExists = await checkBranchExists({ + ctx, + repo: repoInfo, + branchName: args.testBranch, + }); + core.info(`✅ Branch check: "${args.testBranch}" exists = ${branchExists}`); + + core.info("🎉 E2E test completed successfully!"); + + return { + success: true, + testsRun: 7, + repoInfo, + prNumber, + branchExists, + }; + } catch (error) { + core.error(`❌ E2E test failed: ${error}`); + core.setFailed(`E2E test failed: ${error}`); + return { success: false, error: String(error) }; + } +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 0000000..3364e2a --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@tkstang/ci-scripts", + "private": true, + "type": "module", + "dependencies": { + "github-typescript-utils": "file:../../" + } +} diff --git a/.github/scripts/pnpm-lock.yaml b/.github/scripts/pnpm-lock.yaml new file mode 100644 index 0000000..8e8a76f --- /dev/null +++ b/.github/scripts/pnpm-lock.yaml @@ -0,0 +1,223 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + github-typescript-utils: + specifier: file:../../ + version: file:../..(@actions/core@1.11.1)(@actions/github@6.0.1) + +packages: + + '@actions/core@1.11.1': + resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==} + + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + + '@actions/github@6.0.1': + resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==} + + '@actions/http-client@2.2.3': + resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==} + + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@20.0.0': + resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-paginate-rest@9.2.2': + resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@10.4.1': + resolution: {integrity: sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/types@12.6.0': + resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + + github-typescript-utils@file:../..: + resolution: {directory: ../.., type: directory} + engines: {node: '>=22.17.0', pnpm: '>=10.13.1'} + peerDependencies: + '@actions/core': ^1.10.0 + '@actions/github': ^6.0.0 + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@actions/core@1.11.1': + dependencies: + '@actions/exec': 1.1.1 + '@actions/http-client': 2.2.3 + + '@actions/exec@1.1.1': + dependencies: + '@actions/io': 1.1.3 + + '@actions/github@6.0.1': + dependencies: + '@actions/http-client': 2.2.3 + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.2) + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + undici: 5.29.0 + + '@actions/http-client@2.2.3': + dependencies: + tunnel: 0.0.6 + undici: 5.29.0 + + '@actions/io@1.1.3': {} + + '@fastify/busboy@2.1.1': {} + + '@octokit/auth-token@4.0.0': {} + + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/openapi-types@20.0.0': {} + + '@octokit/openapi-types@24.2.0': {} + + '@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + + '@octokit/plugin-rest-endpoint-methods@10.4.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/types@12.6.0': + dependencies: + '@octokit/openapi-types': 20.0.0 + + '@octokit/types@13.10.0': + dependencies: + '@octokit/openapi-types': 24.2.0 + + before-after-hook@2.2.3: {} + + deprecation@2.3.1: {} + + github-typescript-utils@file:../..(@actions/core@1.11.1)(@actions/github@6.0.1): + dependencies: + '@actions/core': 1.11.1 + '@actions/github': 6.0.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + tunnel@0.0.6: {} + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + universal-user-agent@6.0.1: {} + + wrappy@1.0.2: {} diff --git a/.github/scripts/tsconfig.json b/.github/scripts/tsconfig.json new file mode 100644 index 0000000..d60f842 --- /dev/null +++ b/.github/scripts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "github-typescript-utils": ["../../dist/index.d.ts"] + } + }, + "include": ["*.ts"], + "exclude": ["node_modules"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2cb7c1..9fbde8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -34,18 +32,135 @@ jobs: - name: Type check run: pnpm run type-check - build: - name: Build + test: + name: Test Suite + needs: lint runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm run test:run + + - name: Run tests with coverage + run: | + pnpm run test:coverage > coverage_output.txt 2>&1 + + - name: Coverage Summary + run: | + # Debug: Check what files exist + echo "Debug: Checking coverage files..." + ls -la coverage/ || echo "No coverage directory found" + + echo "## 📊 Test Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Extract test results + TEST_COUNT=$(grep -o '[0-9]\+ passed' coverage_output.txt | head -1 | grep -o '[0-9]\+' || echo "0") + + # Check if coverage file exists and extract coverage + if [ -f coverage/coverage-summary.json ]; then + OVERALL_COVERAGE=$(node -e " + const fs = require('fs'); + const coverage = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8')); + console.log(coverage.total.statements.pct + '%'); + ") + + echo "| Metric | Coverage |" >> $GITHUB_STEP_SUMMARY + echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY + + # Add overall coverage summary + node -e " + const fs = require('fs'); + const coverage = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8')); + const total = coverage.total; + console.log(\`| **Statements** | \${total.statements.pct}% (\${total.statements.covered}/\${total.statements.total}) |\`); + console.log(\`| **Branches** | \${total.branches.pct}% (\${total.branches.covered}/\${total.branches.total}) |\`); + console.log(\`| **Functions** | \${total.functions.pct}% (\${total.functions.covered}/\${total.functions.total}) |\`); + console.log(\`| **Lines** | \${total.lines.pct}% (\${total.lines.covered}/\${total.lines.total}) |\`); + " >> $GITHUB_STEP_SUMMARY + else + OVERALL_COVERAGE="N/A" + echo "Coverage data not available" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ **${TEST_COUNT} tests passed** with **${OVERALL_COVERAGE} overall coverage**" >> $GITHUB_STEP_SUMMARY + + test-e2e: + name: E2E Test with github-typescript needs: lint + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + deployments: read steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: - version: 10 + node-version: 22 + cache: pnpm + + # Install main package dependencies and build. These steps are only + # necessary because the example used for the e2e tewst is resolving + # utils via file:../../ to use the current local version of the package. + # Normally, the dependency would be the published version of the package. + - name: Install dependencies + run: pnpm install + + - name: Build package + run: pnpm run build + + # Install scripts dependencies (including local utils via file:../../) + - name: Setup scripts dependencies + run: | + cd .github/scripts + pnpm install + + # Run the example script through the published wrapper + - name: Run E2E test via github-typescript + uses: tkstang/github-typescript@v1 + with: + working-directory: .github/scripts + ts-file: example.ts + node-version: "22" + args: | + { + "testMessage": "Hello E2E Test!", + "testLabels": ["e2e-test", "automated"], + "testBranch": "main" + } + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test, test-e2e] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 @@ -82,8 +197,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -99,4 +212,4 @@ jobs: run: pnpm run build - name: Publish dry run - run: pnpm publish --dry-run + run: pnpm publish --dry-run --no-git-checks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b32210d..e990aaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - "v*" permissions: - contents: read + contents: write # Need write access to create releases id-token: write jobs: @@ -44,3 +44,30 @@ jobs: run: pnpm publish --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: publish # Wait for npm publish to succeed + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + body: | + ## đŸ“Ļ Installation + ```bash + npm install github-typescript-utils@${{ github.ref_name }} + # or + pnpm add github-typescript-utils@${{ github.ref_name }} + ``` + + ## 📋 Full Changelog + See below for auto-generated release notes. diff --git a/.gitignore b/.gitignore index f975b2a..1096c17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Dependencies node_modules/ -pnpm-lock.yaml # Build outputs dist/ diff --git a/README.md b/README.md index 2498b18..ac16dd8 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,27 @@ This package provides a comprehensive set of TypeScript utilities for interactin ## Features -- ✅ **Sticky Comments** - Create and manage persistent, updatable comments -- ✅ **Pull Request Utilities** - Find, label, and manage pull requests -- ✅ **Comment Search** - Search and filter issue/PR comments +### 📋 Complete Function Reference + +| Category | Functions | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| **đŸ’Ŧ Comments** | `createStickyComment`, `findCommentByIdentifier`, `searchComments`, `deleteComment`, `deleteStickyComment` | Create, find, search, and manage issue/PR comments | +| **🔀 Pull Requests** | `findPullRequestsByLabels`, `getPullRequest`, `addLabelsToPullRequest`, `removeLabelsFromPullRequest`, `pullRequestHasLabels`, `getPullRequestFiles` | Find, manage, and interact with pull requests | +| **🔍 Advanced PR Search** | `findPRsWithLabels`, `searchPullRequests`, `findOpenPRsWithLabel`, `checkLabelConflicts` | Advanced pull request search and label conflict detection | +| **đŸŒŋ Branch Management** | `checkBranchExists`, `listAllBranches`, `getBranchProtection`, `getDefaultBranch` | Branch existence, listing, and protection management | +| **🚀 Deployments** | `listDeployments`, `getDeploymentStatuses`, `setDeploymentStatus`, `deleteDeployment`, `createDeployment` | Deployment lifecycle management | +| **🔧 Context & Utils** | `getRepoInfo`, `getCurrentPullRequestNumber`, `getCurrentIssueNumber`, `isPullRequestContext`, `isIssueContext`, `getCurrentSHA`, `getCurrentBranch`, `getRepositoryUrl`, `getIssueUrl`, `getPullRequestUrl` | GitHub Actions context extraction and URL helpers | +| **📝 Text & Formatting** | `escapeMarkdown`, `codeBlock`, `createMarkdownTable`, `truncateText`, `formatDate`, `parseGitHubDate`, `delay` | Text formatting, markdown utilities, and date handling | +| **🔤 String Utilities** | `snakeToCamel`, `camelToSnake`, `kebabToCamel`, `camelToKebab`, `capitalize`, `toTitleCase` | String case conversion and text transformation | +| **âš™ī¸ Input Processing** | `sanitizeInput`, `sanitizeInputs`, `getBranch` | Input sanitization and branch extraction from various GitHub events | + +### ✨ Key Features + - ✅ **Type Safety** - Full TypeScript support with comprehensive type definitions -- ✅ **Context Helpers** - Extract repository and event information from GitHub Actions context -- ✅ **Markdown Utilities** - Format and escape content for GitHub markdown +- ✅ **Universal Compatibility** - Works with all GitHub event types and contexts +- ✅ **Comprehensive Testing** - 110+ tests with 94%+ code coverage +- ✅ **GitHub API Optimized** - Efficient API usage with proper error handling and pagination support +- ✅ **Developer Friendly** - Intuitive APIs with TypeScript intellisense and JSDoc documentation --- @@ -37,6 +52,70 @@ yarn add github-typescript-utils ## Usage with github-typescript +### Dependency Setup + +For `esbuild` to resolve `github-typescript-utils`, you need the package available in `node_modules`. Choose one approach: + +#### Option A: Root Dependencies (Simplest) + +Add to your repository's root `package.json`: + +```json +{ + "dependencies": { + "github-typescript-utils": "^0.2.0" + } +} +``` + +Workflow setup: + +```yaml +- uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm +- run: pnpm install + +- uses: tkstang/github-typescript@v1 + with: + ts-file: .github/scripts/manage-pr.ts +``` + +#### Option B: Isolated CI Dependencies + +Create `.github/scripts/package.json`: + +```json +{ + "name": "ci-scripts", + "private": true, + "type": "module", + "dependencies": { + "github-typescript-utils": "^0.2.0" + } +} +``` + +Workflow setup: + +```yaml +- uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: .github/scripts/pnpm-lock.yaml +- run: pnpm install + working-directory: .github/scripts + +- uses: tkstang/github-typescript@v1 + with: + working-directory: .github/scripts + ts-file: manage-pr.ts +``` + +### Script Example + Create a TypeScript script that imports the utilities: ```ts @@ -278,6 +357,180 @@ const table = createMarkdownTable( const short = truncateText("Very long text...", 50); ``` +### String Utilities + +#### Case Conversion + +```ts +import { + snakeToCamel, + camelToSnake, + kebabToCamel, + camelToKebab, + capitalize, + toTitleCase, +} from "github-typescript-utils"; + +// Convert between naming conventions +const camelCase = snakeToCamel("hello_world"); // "helloWorld" +const snakeCase = camelToSnake("helloWorld"); // "hello_world" +const kebabCase = camelToKebab("helloWorld"); // "hello-world" +const camelFromKebab = kebabToCamel("hello-world"); // "helloWorld" + +// Text transformation +const capitalized = capitalize("hello"); // "Hello" +const titleCase = toTitleCase("hello world"); // "Hello World" +``` + +### Branch Management + +#### Branch Operations + +```ts +import { + checkBranchExists, + listAllBranches, + getBranchProtection, + getDefaultBranch, +} from "github-typescript-utils"; + +// Check if branch exists +const exists = await checkBranchExists({ + ctx: { core, github, context }, + repo, + branch: "feature-branch", +}); + +// List all branches +const branches = await listAllBranches({ + ctx: { core, github, context }, + repo, + limit: 50, +}); + +// Get branch protection rules +const protection = await getBranchProtection({ + ctx: { core, github, context }, + repo, + branch: "main", +}); + +// Get default branch +const defaultBranch = await getDefaultBranch({ + ctx: { core, github, context }, + repo, +}); +``` + +### Deployment Management + +#### Deployment Lifecycle + +```ts +import { + listDeployments, + createDeployment, + setDeploymentStatus, + getDeploymentStatuses, + deleteDeployment, +} from "github-typescript-utils"; + +// Create a deployment +const deployment = await createDeployment({ + ctx: { core, github, context }, + repo, + ref: "main", + environment: "production", + description: "Deploy v1.0.0", +}); + +// Set deployment status +await setDeploymentStatus({ + ctx: { core, github, context }, + repo, + deploymentId: deployment.id, + state: "success", + description: "Deployment completed successfully", +}); + +// List deployments +const deployments = await listDeployments({ + ctx: { core, github, context }, + repo, + environment: "production", +}); +``` + +### Advanced PR Search + +#### Enhanced PR Operations + +```ts +import { + findPRsWithLabels, + searchPullRequests, + checkLabelConflicts, + findOpenPRsWithLabel, +} from "github-typescript-utils"; + +// Find PRs with multiple labels +const prs = await findPRsWithLabels({ + ctx: { core, github, context }, + repo, + labels: ["bug", "urgent"], + excludePRs: [123], // Exclude specific PR numbers +}); + +// Advanced PR search +const searchResults = await searchPullRequests({ + ctx: { core, github, context }, + repo, + options: { + labels: ["feature"], + author: "dependabot[bot]", + state: "open", + }, +}); + +// Check for label conflicts +const conflict = await checkLabelConflicts({ + ctx: { core, github, context }, + repo, + prNumber: 123, + label: "sync-branch: main", +}); + +if (conflict.hasConflict) { + core.warning(`Label conflict with PR #${conflict.conflictingPR?.number}`); +} +``` + +### Input Processing + +#### Input Sanitization and Branch Extraction + +```ts +import { + sanitizeInput, + sanitizeInputs, + getBranch, +} from "github-typescript-utils"; + +// Remove quotes from workflow inputs +const cleanInput = sanitizeInput('"quoted-value"'); // "quoted-value" + +// Sanitize all string properties in an object +const cleanInputs = sanitizeInputs({ + name: '"John"', + age: 30, + title: '"Developer"', +}); // { name: "John", age: 30, title: "Developer" } + +// Extract branch from any GitHub event +const branch = getBranch({ core, github, context }); +// Works with: pull_request, push, workflow_run, etc. +``` + --- ## Type Definitions @@ -286,13 +539,24 @@ The package exports comprehensive TypeScript types: ```ts import type { + // Core types GitHubContext, RepoInfo, PullRequest, IssueComment, + + // Comment types StickyCommentOptions, CommentSearchOptions, + + // Pull request types PullRequestSearchOptions, + PullRequestFile, + AdvancedPRSearchOptions, + + // Deployment types + Deployment, + DeploymentStatus, } from "github-typescript-utils"; ``` diff --git a/biome.json b/biome.json index f9f815c..bcef9e3 100644 --- a/biome.json +++ b/biome.json @@ -1,14 +1,31 @@ { - "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", - "organizeImports": { - "enabled": true - }, + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "linter": { "enabled": true, "rules": { "recommended": true, "style": { - "useNamingConvention": "warn", + "useNamingConvention": { + "level": "warn", + "options": { + "strictCase": false, + "requireAscii": false, + "conventions": [ + { + "selector": { + "kind": "objectLiteralProperty" + }, + "formats": ["camelCase", "snake_case", "CONSTANT_CASE"] + }, + { + "selector": { + "kind": "typeProperty" + }, + "formats": ["camelCase", "snake_case", "CONSTANT_CASE"] + } + ] + } + }, "useBlockStatements": "warn" }, "complexity": { @@ -38,7 +55,14 @@ } }, "files": { - "include": ["src/**/*.ts", "src/**/*.js", "*.json", "*.md"], - "ignore": ["node_modules/**", "dist/**", "coverage/**"] + "ignoreUnknown": false, + "includes": ["src/**/*.ts", "src/**/*.js", "*.json", "*.md"] + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } } } diff --git a/package.json b/package.json index 5c01aed..9735fa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-typescript-utils", - "version": "0.1.0", + "version": "0.2.0", "description": "Utility functions for GitHub REST API interactions with github-typescript", "type": "module", "main": "./dist/index.js", @@ -41,6 +41,9 @@ "build:watch": "tsc --watch", "clean": "rm -rf dist", "dev": "tsc --watch", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "lint": "biome lint src/", "lint:fix": "biome lint --write src/", "format": "biome format src/", @@ -48,7 +51,7 @@ "check": "biome check src/", "check:fix": "biome check --write src/", "type-check": "tsc --noEmit", - "prepublishOnly": "pnpm run clean && pnpm run build" + "prepublishOnly": "pnpm run clean && pnpm run test:run && pnpm run build" }, "peerDependencies": { "@actions/core": "^1.10.0", @@ -57,9 +60,11 @@ "devDependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0", - "@biomejs/biome": "^2.1.1", + "@biomejs/biome": "2.2.4", "@types/node": "^22.17.0", - "typescript": "^5.8.3" + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.8.3", + "vitest": "^3.2.4" }, "packageManager": "pnpm@10.13.1", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f933365 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1730 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@actions/core': + specifier: ^1.10.1 + version: 1.11.1 + '@actions/github': + specifier: ^6.0.0 + version: 6.0.1 + '@biomejs/biome': + specifier: 2.2.4 + version: 2.2.4 + '@types/node': + specifier: ^22.17.0 + version: 22.18.3 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.18.3)) + typescript: + specifier: ^5.8.3 + version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.18.3) + +packages: + + '@actions/core@1.11.1': + resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==} + + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + + '@actions/github@6.0.1': + resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==} + + '@actions/http-client@2.2.3': + resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==} + + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.2.4': + resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.2.4': + resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.2.4': + resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.2.4': + resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.2.4': + resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.2.4': + resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.2.4': + resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.2.4': + resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.2.4': + resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.25.9': + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.9': + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.9': + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.9': + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.9': + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.9': + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.9': + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.9': + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@20.0.0': + resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-paginate-rest@9.2.2': + resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@10.4.1': + resolution: {integrity: sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/types@12.6.0': + resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.50.1': + resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.50.1': + resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.18.3': + resolution: {integrity: sha512-gTVM8js2twdtqM+AE2PdGEe9zGQY4UvmFjan9rZcVb6FGdStfjWoWejdmy4CfWVO9rh5MiYQGZloKAGkJt8lMw==} + + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.5: + resolution: {integrity: sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.1.5: + resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + 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 + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@actions/core@1.11.1': + dependencies: + '@actions/exec': 1.1.1 + '@actions/http-client': 2.2.3 + + '@actions/exec@1.1.1': + dependencies: + '@actions/io': 1.1.3 + + '@actions/github@6.0.1': + dependencies: + '@actions/http-client': 2.2.3 + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.2) + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + undici: 5.29.0 + + '@actions/http-client@2.2.3': + dependencies: + tunnel: 0.0.6 + undici: 5.29.0 + + '@actions/io@1.1.3': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.2.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.2.4 + '@biomejs/cli-darwin-x64': 2.2.4 + '@biomejs/cli-linux-arm64': 2.2.4 + '@biomejs/cli-linux-arm64-musl': 2.2.4 + '@biomejs/cli-linux-x64': 2.2.4 + '@biomejs/cli-linux-x64-musl': 2.2.4 + '@biomejs/cli-win32-arm64': 2.2.4 + '@biomejs/cli-win32-x64': 2.2.4 + + '@biomejs/cli-darwin-arm64@2.2.4': + optional: true + + '@biomejs/cli-darwin-x64@2.2.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.2.4': + optional: true + + '@biomejs/cli-linux-arm64@2.2.4': + optional: true + + '@biomejs/cli-linux-x64-musl@2.2.4': + optional: true + + '@biomejs/cli-linux-x64@2.2.4': + optional: true + + '@biomejs/cli-win32-arm64@2.2.4': + optional: true + + '@biomejs/cli-win32-x64@2.2.4': + optional: true + + '@esbuild/aix-ppc64@0.25.9': + optional: true + + '@esbuild/android-arm64@0.25.9': + optional: true + + '@esbuild/android-arm@0.25.9': + optional: true + + '@esbuild/android-x64@0.25.9': + optional: true + + '@esbuild/darwin-arm64@0.25.9': + optional: true + + '@esbuild/darwin-x64@0.25.9': + optional: true + + '@esbuild/freebsd-arm64@0.25.9': + optional: true + + '@esbuild/freebsd-x64@0.25.9': + optional: true + + '@esbuild/linux-arm64@0.25.9': + optional: true + + '@esbuild/linux-arm@0.25.9': + optional: true + + '@esbuild/linux-ia32@0.25.9': + optional: true + + '@esbuild/linux-loong64@0.25.9': + optional: true + + '@esbuild/linux-mips64el@0.25.9': + optional: true + + '@esbuild/linux-ppc64@0.25.9': + optional: true + + '@esbuild/linux-riscv64@0.25.9': + optional: true + + '@esbuild/linux-s390x@0.25.9': + optional: true + + '@esbuild/linux-x64@0.25.9': + optional: true + + '@esbuild/netbsd-arm64@0.25.9': + optional: true + + '@esbuild/netbsd-x64@0.25.9': + optional: true + + '@esbuild/openbsd-arm64@0.25.9': + optional: true + + '@esbuild/openbsd-x64@0.25.9': + optional: true + + '@esbuild/openharmony-arm64@0.25.9': + optional: true + + '@esbuild/sunos-x64@0.25.9': + optional: true + + '@esbuild/win32-arm64@0.25.9': + optional: true + + '@esbuild/win32-ia32@0.25.9': + optional: true + + '@esbuild/win32-x64@0.25.9': + optional: true + + '@fastify/busboy@2.1.1': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@octokit/auth-token@4.0.0': {} + + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/openapi-types@20.0.0': {} + + '@octokit/openapi-types@24.2.0': {} + + '@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + + '@octokit/plugin-rest-endpoint-methods@10.4.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/types@12.6.0': + dependencies: + '@octokit/openapi-types': 20.0.0 + + '@octokit/types@13.10.0': + dependencies: + '@octokit/openapi-types': 24.2.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.50.1': + optional: true + + '@rollup/rollup-android-arm64@4.50.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.1': + optional: true + + '@rollup/rollup-darwin-x64@4.50.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.1': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.1': + optional: true + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.18.3': + dependencies: + undici-types: 6.21.0 + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.18.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.5 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.19 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.18.3) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@22.18.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 7.1.5(@types/node@22.18.3) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.19 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.3 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + balanced-match@1.0.2: {} + + before-after-hook@2.2.3: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deprecation@2.3.1: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.9: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.9 + '@esbuild/android-arm': 0.25.9 + '@esbuild/android-arm64': 0.25.9 + '@esbuild/android-x64': 0.25.9 + '@esbuild/darwin-arm64': 0.25.9 + '@esbuild/darwin-x64': 0.25.9 + '@esbuild/freebsd-arm64': 0.25.9 + '@esbuild/freebsd-x64': 0.25.9 + '@esbuild/linux-arm': 0.25.9 + '@esbuild/linux-arm64': 0.25.9 + '@esbuild/linux-ia32': 0.25.9 + '@esbuild/linux-loong64': 0.25.9 + '@esbuild/linux-mips64el': 0.25.9 + '@esbuild/linux-ppc64': 0.25.9 + '@esbuild/linux-riscv64': 0.25.9 + '@esbuild/linux-s390x': 0.25.9 + '@esbuild/linux-x64': 0.25.9 + '@esbuild/netbsd-arm64': 0.25.9 + '@esbuild/netbsd-x64': 0.25.9 + '@esbuild/openbsd-arm64': 0.25.9 + '@esbuild/openbsd-x64': 0.25.9 + '@esbuild/openharmony-arm64': 0.25.9 + '@esbuild/sunos-x64': 0.25.9 + '@esbuild/win32-arm64': 0.25.9 + '@esbuild/win32-ia32': 0.25.9 + '@esbuild/win32-x64': 0.25.9 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.2.2: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.50.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.1 + '@rollup/rollup-android-arm64': 4.50.1 + '@rollup/rollup-darwin-arm64': 4.50.1 + '@rollup/rollup-darwin-x64': 4.50.1 + '@rollup/rollup-freebsd-arm64': 4.50.1 + '@rollup/rollup-freebsd-x64': 4.50.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 + '@rollup/rollup-linux-arm-musleabihf': 4.50.1 + '@rollup/rollup-linux-arm64-gnu': 4.50.1 + '@rollup/rollup-linux-arm64-musl': 4.50.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 + '@rollup/rollup-linux-ppc64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-musl': 4.50.1 + '@rollup/rollup-linux-s390x-gnu': 4.50.1 + '@rollup/rollup-linux-x64-gnu': 4.50.1 + '@rollup/rollup-linux-x64-musl': 4.50.1 + '@rollup/rollup-openharmony-arm64': 4.50.1 + '@rollup/rollup-win32-arm64-msvc': 4.50.1 + '@rollup/rollup-win32-ia32-msvc': 4.50.1 + '@rollup/rollup-win32-x64-msvc': 4.50.1 + fsevents: 2.3.3 + + semver@7.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.9.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} + + tunnel@0.0.6: {} + + typescript@5.9.2: {} + + undici-types@6.21.0: {} + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + universal-user-agent@6.0.1: {} + + vite-node@3.2.4(@types/node@22.18.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.5(@types/node@22.18.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.1.5(@types/node@22.18.3): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.3 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@22.18.3): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@22.18.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.19 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.5(@types/node@22.18.3) + vite-node: 3.2.4(@types/node@22.18.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.18.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} diff --git a/src/__tests__/advanced-pr-utils.test.ts b/src/__tests__/advanced-pr-utils.test.ts new file mode 100644 index 0000000..5f5214e --- /dev/null +++ b/src/__tests__/advanced-pr-utils.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + checkLabelConflicts, + findOpenPRsWithLabel, + findPRsWithLabels, + searchPullRequests, +} from '../advanced-pr-utils.js'; +import { createSimpleMockContext } from './test-utils.js'; + +describe('findPRsWithLabels', () => { + it('should find PRs with all specified labels', async () => { + const mockPRs = [ + { + number: 1, + labels: [{ name: 'bug' }, { name: 'urgent' }], + }, + { + number: 2, + labels: [{ name: 'bug' }], + }, + { + number: 3, + labels: [{ name: 'bug' }, { name: 'urgent' }, { name: 'frontend' }], + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findPRsWithLabels({ + ctx, + repo, + labels: ['bug', 'urgent'], + }); + + expect(result).toHaveLength(2); + expect(result[0].number).toBe(1); + expect(result[1].number).toBe(3); + expect(ctx.core.info).toHaveBeenCalledWith('Searching for PRs with labels: bug, urgent'); + }); + + it('should exclude specified PRs', async () => { + const mockPRs = [ + { + number: 1, + labels: [{ name: 'bug' }], + }, + { + number: 2, + labels: [{ name: 'bug' }], + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findPRsWithLabels({ + ctx, + repo, + labels: ['bug'], + excludePRs: [1], + }); + + expect(result).toHaveLength(1); + expect(result[0].number).toBe(2); + }); + + it('should throw error when no labels specified', async () => { + const ctx = createSimpleMockContext(); + + const repo = { owner: 'test', repo: 'test-repo' }; + + await expect(findPRsWithLabels({ ctx, repo, labels: [] })).rejects.toThrow( + 'At least one label must be specified' + ); + }); +}); + +describe('searchPullRequests', () => { + it('should search PRs with multiple criteria', async () => { + const mockPRs = [ + { + number: 1, + user: { login: 'alice' }, + labels: [{ name: 'bug' }], + }, + { + number: 2, + user: { login: 'bob' }, + labels: [{ name: 'feature' }], + }, + { + number: 3, + user: { login: 'alice' }, + labels: [{ name: 'bug' }], + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchPullRequests({ + ctx, + repo, + options: { + labels: ['bug'], + author: 'alice', + state: 'open', + }, + }); + + expect(result).toHaveLength(2); + expect(result[0].number).toBe(1); + expect(result[1].number).toBe(3); + expect(mockGithub.rest.pulls.list).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + state: 'open', + sort: 'created', + direction: 'desc', + per_page: 100, + page: 1, + }); + }); + + it('should handle empty search results', async () => { + const mockGithub = { + rest: { + pulls: { + list: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchPullRequests({ + ctx, + repo, + options: { labels: ['nonexistent'] }, + }); + + expect(result).toHaveLength(0); + }); +}); + +describe('findOpenPRsWithLabel', () => { + it('should find open PRs with specific label', async () => { + const mockPRs = [ + { + number: 1, + labels: [{ name: 'bug' }], + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findOpenPRsWithLabel({ + ctx, + repo, + label: 'bug', + }); + + expect(result).toHaveLength(1); + expect(result[0].number).toBe(1); + }); +}); + +describe('checkLabelConflicts', () => { + it('should detect label conflicts', async () => { + const mockPRs = [ + { + number: 2, + labels: [{ name: 'sync-branch: sbntls-1' }], + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await checkLabelConflicts({ + ctx, + repo, + prNumber: 1, + label: 'sync-branch: sbntls-1', + }); + + expect(result.hasConflict).toBe(true); + expect(result.conflictingPR?.number).toBe(2); + }); + + it('should return no conflict when label is not in use', async () => { + const mockGithub = { + rest: { + pulls: { + list: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await checkLabelConflicts({ + ctx, + repo, + prNumber: 1, + label: 'sync-branch: sbntls-1', + }); + + expect(result.hasConflict).toBe(false); + expect(result.conflictingPR).toBeUndefined(); + }); + + it('should exclude the current PR from conflict check', async () => { + const mockPRs = [ + { + number: 1, + labels: [{ name: 'sync-branch: sbntls-1' }], + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await checkLabelConflicts({ + ctx, + repo, + prNumber: 1, + label: 'sync-branch: sbntls-1', + }); + + expect(result.hasConflict).toBe(false); + }); +}); diff --git a/src/__tests__/branch-utils.test.ts b/src/__tests__/branch-utils.test.ts new file mode 100644 index 0000000..081df7a --- /dev/null +++ b/src/__tests__/branch-utils.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + checkBranchExists, + getBranchProtection, + getDefaultBranch, + listAllBranches, +} from '../branch-utils.js'; +import { createSimpleMockContext } from './test-utils.js'; + +describe('checkBranchExists', () => { + it('should return true when branch exists', async () => { + const mockGithub = { + rest: { + repos: { + getBranch: vi.fn().mockResolvedValue({ data: { name: 'main' } }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await checkBranchExists({ ctx, repo, branch: 'main' }); + + expect(result).toBe(true); + expect(mockGithub.rest.repos.getBranch).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + branch: 'main', + }); + }); + + it('should return false when branch does not exist', async () => { + const mockGithub = { + rest: { + repos: { + getBranch: vi.fn().mockRejectedValue({ status: 404 }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await checkBranchExists({ + ctx, + repo, + branch: 'nonexistent', + }); + + expect(result).toBe(false); + }); + + it('should throw error for non-404 errors', async () => { + const mockGithub = { + rest: { + repos: { + getBranch: vi.fn().mockRejectedValue({ status: 500 }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + + await expect(checkBranchExists({ ctx, repo, branch: 'main' })).rejects.toEqual({ status: 500 }); + }); +}); + +describe('listAllBranches', () => { + it('should return list of branch names', async () => { + const mockGithub = { + rest: { + repos: { + listBranches: vi + .fn() + .mockResolvedValueOnce({ + data: [{ name: 'main' }, { name: 'develop' }], + }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await listAllBranches({ ctx, repo }); + + expect(result).toEqual(['main', 'develop']); + expect(mockGithub.rest.repos.listBranches).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + per_page: 100, + page: 1, + }); + }); + + it('should respect limit parameter', async () => { + const mockGithub = { + rest: { + repos: { + listBranches: vi.fn().mockResolvedValue({ + data: [{ name: 'main' }, { name: 'develop' }, { name: 'feature' }], + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await listAllBranches({ ctx, repo, limit: 2 }); + + expect(result).toEqual(['main', 'develop']); + }); +}); + +describe('getBranchProtection', () => { + it('should return protection rules when they exist', async () => { + const mockProtection = { required_status_checks: null }; + const mockGithub = { + rest: { + repos: { + getBranchProtection: vi.fn().mockResolvedValue({ data: mockProtection }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await getBranchProtection({ ctx, repo, branch: 'main' }); + + expect(result).toEqual(mockProtection); + }); + + it('should return null when no protection rules exist', async () => { + const mockGithub = { + rest: { + repos: { + getBranchProtection: vi.fn().mockRejectedValue({ status: 404 }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await getBranchProtection({ ctx, repo, branch: 'main' }); + + expect(result).toBeNull(); + }); +}); + +describe('getDefaultBranch', () => { + it('should return the default branch name', async () => { + const mockGithub = { + rest: { + repos: { + get: vi.fn().mockResolvedValue({ data: { default_branch: 'main' } }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await getDefaultBranch({ ctx, repo }); + + expect(result).toBe('main'); + expect(mockGithub.rest.repos.get).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + }); + }); +}); diff --git a/src/__tests__/comments.test.ts b/src/__tests__/comments.test.ts new file mode 100644 index 0000000..647b79f --- /dev/null +++ b/src/__tests__/comments.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createStickyComment, + deleteComment, + deleteStickyComment, + findCommentByIdentifier, + searchComments, +} from '../comments.js'; +import { createSimpleMockContext } from './test-utils.js'; + +describe('createStickyComment', () => { + it('should create a new sticky comment when none exists', async () => { + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: [] }), + createComment: vi.fn().mockResolvedValue({ + data: { id: 123, body: 'Test comment' }, + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await createStickyComment({ + ctx, + repo, + issueNumber: 1, + options: { + identifier: 'test-sticky', + body: 'Test comment', + }, + }); + + expect(result).toEqual({ id: 123, body: 'Test comment' }); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + issue_number: 1, + body: '\nTest comment', + }); + }); + + it('should update existing sticky comment when updateIfExists is true', async () => { + const existingComment = { + id: 123, + body: 'Old comment\n\n', + }; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: [existingComment] }), + updateComment: vi.fn().mockResolvedValue({ + data: { id: 123, body: 'Updated comment' }, + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await createStickyComment({ + ctx, + repo, + issueNumber: 1, + options: { + identifier: 'test-sticky', + body: 'Updated comment', + updateIfExists: true, + }, + }); + + expect(result).toEqual({ id: 123, body: 'Updated comment' }); + expect(mockGithub.rest.issues.updateComment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + comment_id: 123, + body: '\nUpdated comment', + }); + }); + + it('should create new comment when updateIfExists is false', async () => { + const existingComment = { + id: 123, + body: 'Old comment\n\n', + }; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: [existingComment] }), + createComment: vi.fn().mockResolvedValue({ + data: { id: 456, body: 'New comment' }, + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await createStickyComment({ + ctx, + repo, + issueNumber: 1, + options: { + identifier: 'test-sticky', + body: 'New comment', + updateIfExists: false, + }, + }); + + expect(result).toEqual({ id: 456, body: 'New comment' }); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); + }); +}); + +describe('findCommentByIdentifier', () => { + it('should find existing sticky comment', async () => { + const stickyComment = { + id: 123, + body: 'Test comment\n\n', + }; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ + data: [{ id: 456, body: 'Regular comment' }, stickyComment], + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findCommentByIdentifier({ + ctx, + repo, + issueNumber: 1, + identifier: 'test-sticky', + }); + + expect(result).toEqual(stickyComment); + }); + + it('should return null when sticky comment not found', async () => { + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ + data: [{ id: 456, body: 'Regular comment' }], + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findCommentByIdentifier({ + ctx, + repo, + issueNumber: 1, + identifier: 'nonexistent', + }); + + expect(result).toBeNull(); + }); +}); + +describe('searchComments', () => { + it('should find comments matching criteria', async () => { + const comments = [ + { + id: 1, + body: 'This contains error message', + user: { login: 'alice' }, + created_at: '2024-01-15T10:00:00Z', + }, + { + id: 2, + body: 'This is a regular comment', + user: { login: 'bob' }, + created_at: '2024-01-16T10:00:00Z', + }, + { + id: 3, + body: 'Another error occurred', + user: { login: 'alice' }, + created_at: '2024-01-17T10:00:00Z', + }, + ]; + + const mockGithub = { + rest: { + issues: { + listComments: vi + .fn() + .mockResolvedValueOnce({ data: comments }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchComments({ + ctx, + repo, + issueNumber: 1, + options: { + bodyContains: 'error', + author: 'alice', + }, + }); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(1); + expect(result[1].id).toBe(3); + }); + + it('should respect limit parameter', async () => { + const comments = [ + { id: 1, body: 'Comment 1', user: { login: 'alice' } }, + { id: 2, body: 'Comment 2', user: { login: 'alice' } }, + { id: 3, body: 'Comment 3', user: { login: 'alice' } }, + ]; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: comments }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchComments({ + ctx, + repo, + issueNumber: 1, + options: { + author: 'alice', + limit: 2, + }, + }); + + expect(result).toHaveLength(2); + }); + + it('should filter by creation date', async () => { + const comments = [ + { + id: 1, + body: 'Old comment', + user: { login: 'alice' }, + created_at: '2024-01-01T10:00:00Z', + }, + { + id: 2, + body: 'New comment', + user: { login: 'alice' }, + created_at: '2024-01-20T10:00:00Z', + }, + ]; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: comments }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchComments({ + ctx, + repo, + issueNumber: 1, + options: { + createdAfter: new Date('2024-01-15T00:00:00Z'), + }, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); + + it('should return empty array when no comments match', async () => { + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchComments({ + ctx, + repo, + issueNumber: 1, + options: { + bodyContains: 'nonexistent', + }, + }); + + expect(result).toHaveLength(0); + }); + + it('should filter by creation date with createdBefore', async () => { + const comments = [ + { + id: 1, + body: 'Old comment', + user: { login: 'alice' }, + created_at: '2024-01-01T10:00:00Z', + }, + { + id: 2, + body: 'New comment', + user: { login: 'alice' }, + created_at: '2024-01-20T10:00:00Z', + }, + ]; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: comments }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchComments({ + ctx, + repo, + issueNumber: 1, + options: { + createdBefore: new Date('2024-01-15T00:00:00Z'), + }, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it('should handle pagination correctly', async () => { + const firstPage = [ + { id: 1, body: 'Comment 1', user: { login: 'alice' } }, + { id: 2, body: 'Comment 2', user: { login: 'alice' } }, + ]; + const secondPage = [{ id: 3, body: 'Comment 3', user: { login: 'alice' } }]; + + const mockGithub = { + rest: { + issues: { + listComments: vi + .fn() + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: secondPage }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await searchComments({ + ctx, + repo, + issueNumber: 1, + options: {}, + }); + + // searchComments only makes one API call, not paginated + expect(result).toHaveLength(2); // Only gets first page + expect(mockGithub.rest.issues.listComments).toHaveBeenCalledTimes(1); + }); +}); + +describe('deleteComment', () => { + it('should delete a comment by ID', async () => { + const mockGithub = { + rest: { + issues: { + deleteComment: vi.fn().mockResolvedValue({}), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + await deleteComment({ + ctx, + repo, + commentId: 123, + }); + + expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + comment_id: 123, + }); + expect(ctx.core.info).toHaveBeenCalledWith('Deleting comment ID: 123'); + }); +}); + +describe('deleteStickyComment', () => { + it('should delete existing sticky comment and return true', async () => { + const existingComment = { + id: 123, + body: 'Test comment\n\n', + }; + + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: [existingComment] }), + deleteComment: vi.fn().mockResolvedValue({}), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await deleteStickyComment({ + ctx, + repo, + issueNumber: 1, + identifier: 'test-sticky', + }); + + expect(result).toBe(true); + expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + comment_id: 123, + }); + }); + + it('should return false when sticky comment not found', async () => { + const mockGithub = { + rest: { + issues: { + listComments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await deleteStickyComment({ + ctx, + repo, + issueNumber: 1, + identifier: 'nonexistent', + }); + + expect(result).toBe(false); + expect(ctx.core.info).toHaveBeenCalledWith( + "Sticky comment with identifier 'nonexistent' not found" + ); + }); +}); diff --git a/src/__tests__/deployment-utils.test.ts b/src/__tests__/deployment-utils.test.ts new file mode 100644 index 0000000..a5014b8 --- /dev/null +++ b/src/__tests__/deployment-utils.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDeployment, + deleteDeployment, + getDeploymentStatuses, + listDeployments, + setDeploymentStatus, +} from '../deployment-utils.js'; +import { createSimpleMockContext } from './test-utils.js'; + +describe('listDeployments', () => { + it('should return list of deployments', async () => { + const mockDeployments = [ + { id: 1, environment: 'production', ref: 'main' }, + { id: 2, environment: 'staging', ref: 'develop' }, + ]; + + const mockGithub = { + rest: { + repos: { + listDeployments: vi + .fn() + .mockResolvedValueOnce({ data: mockDeployments }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await listDeployments({ ctx, repo }); + + expect(result).toEqual(mockDeployments); + expect(mockGithub.rest.repos.listDeployments).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + per_page: 100, + page: 1, + }); + }); + + it('should filter by ref when provided', async () => { + const mockGithub = { + rest: { + repos: { + listDeployments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + await listDeployments({ ctx, repo, ref: 'main' }); + + expect(mockGithub.rest.repos.listDeployments).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + ref: 'main', + per_page: 100, + page: 1, + }); + }); + + it('should filter by environment when provided', async () => { + const mockGithub = { + rest: { + repos: { + listDeployments: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + await listDeployments({ ctx, repo, environment: 'production' }); + + expect(mockGithub.rest.repos.listDeployments).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + environment: 'production', + per_page: 100, + page: 1, + }); + }); +}); + +describe('getDeploymentStatuses', () => { + it('should return deployment statuses', async () => { + const mockStatuses = [ + { id: 1, state: 'success' }, + { id: 2, state: 'pending' }, + ]; + + const mockGithub = { + rest: { + repos: { + listDeploymentStatuses: vi.fn().mockResolvedValue({ data: mockStatuses }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await getDeploymentStatuses({ + ctx, + repo, + deploymentId: 123, + }); + + expect(result).toEqual(mockStatuses); + expect(mockGithub.rest.repos.listDeploymentStatuses).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + deployment_id: 123, + }); + }); +}); + +describe('setDeploymentStatus', () => { + it('should create deployment status', async () => { + const mockStatus = { id: 1, state: 'success' }; + const mockGithub = { + rest: { + repos: { + createDeploymentStatus: vi.fn().mockResolvedValue({ data: mockStatus }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await setDeploymentStatus({ + ctx, + repo, + deploymentId: 123, + state: 'success', + description: 'Deployment successful', + }); + + expect(result).toEqual(mockStatus); + expect(mockGithub.rest.repos.createDeploymentStatus).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + deployment_id: 123, + state: 'success', + description: 'Deployment successful', + }); + }); +}); + +describe('deleteDeployment', () => { + it('should set deployment to inactive and delete it', async () => { + const mockGithub = { + rest: { + repos: { + createDeploymentStatus: vi.fn().mockResolvedValue({ data: {} }), + deleteDeployment: vi.fn().mockResolvedValue({}), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + await deleteDeployment({ ctx, repo, deploymentId: 123 }); + + expect(mockGithub.rest.repos.createDeploymentStatus).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + deployment_id: 123, + state: 'inactive', + }); + + expect(mockGithub.rest.repos.deleteDeployment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + deployment_id: 123, + }); + }); +}); + +describe('createDeployment', () => { + it('should create a new deployment', async () => { + const mockDeployment = { id: 123, environment: 'production' }; + const mockGithub = { + rest: { + repos: { + createDeployment: vi.fn().mockResolvedValue({ data: mockDeployment }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await createDeployment({ + ctx, + repo, + ref: 'main', + environment: 'production', + description: 'Deploy to production', + }); + + expect(result).toEqual(mockDeployment); + expect(mockGithub.rest.repos.createDeployment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + ref: 'main', + environment: 'production', + auto_merge: true, + description: 'Deploy to production', + }); + }); + + it('should include optional parameters when provided', async () => { + const mockGithub = { + rest: { + repos: { + createDeployment: vi.fn().mockResolvedValue({ data: {} }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + await createDeployment({ + ctx, + repo, + ref: 'main', + environment: 'production', + payload: { version: '1.0.0' }, + autoMerge: false, + requiredContexts: ['ci/test'], + }); + + expect(mockGithub.rest.repos.createDeployment).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + ref: 'main', + environment: 'production', + auto_merge: false, + payload: { version: '1.0.0' }, + required_contexts: ['ci/test'], + }); + }); +}); diff --git a/src/__tests__/input-utils.test.ts b/src/__tests__/input-utils.test.ts new file mode 100644 index 0000000..6b1aa09 --- /dev/null +++ b/src/__tests__/input-utils.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { getBranch, sanitizeInput, sanitizeInputs } from '../input-utils.js'; +import { createMockContext, createMockGitHubContext } from './test-utils.js'; + +describe('sanitizeInput', () => { + it('should remove leading and trailing quotes from strings', () => { + expect(sanitizeInput('"hello"')).toBe('hello'); + expect(sanitizeInput("'world'")).toBe("'world'"); // Only removes double quotes + expect(sanitizeInput('""test""')).toBe('"test"'); // Only removes outer quotes + }); + + it('should return non-string values unchanged', () => { + expect(sanitizeInput(123)).toBe(123); + expect(sanitizeInput(true)).toBe(true); + expect(sanitizeInput(null)).toBe(null); + expect(sanitizeInput(undefined)).toBe(undefined); + expect(sanitizeInput({ key: 'value' })).toEqual({ key: 'value' }); + }); + + it('should handle strings without quotes', () => { + expect(sanitizeInput('hello')).toBe('hello'); + expect(sanitizeInput('')).toBe(''); + }); + + it('should handle edge cases with quotes', () => { + expect(sanitizeInput('"')).toBe(''); // Single quote character gets removed + expect(sanitizeInput('""')).toBe(''); // Empty quoted string + expect(sanitizeInput('"a"')).toBe('a'); // Single character + expect(sanitizeInput('"hello world"')).toBe('hello world'); // String with spaces + }); +}); + +describe('sanitizeInputs', () => { + it('should sanitize all string values in an object', () => { + const input = { + name: '"John"', + age: 30, + active: true, + description: '"A developer"', + }; + + const expected = { + name: 'John', + age: 30, + active: true, + description: 'A developer', + }; + + expect(sanitizeInputs(input)).toEqual(expected); + }); + + it('should handle empty objects', () => { + expect(sanitizeInputs({})).toEqual({}); + }); + + it('should handle objects with no string values', () => { + const input = { count: 42, enabled: false }; + expect(sanitizeInputs(input)).toEqual(input); + }); + + it('should handle nested objects and arrays', () => { + const input = { + name: '"John"', + config: { title: '"My App"', debug: true }, + tags: ['"tag1"', '"tag2"', 123], + }; + + // sanitizeInputs only handles top-level properties, not nested ones + const expected = { + name: 'John', + config: { title: '"My App"', debug: true }, // nested objects not processed + tags: ['"tag1"', '"tag2"', 123], // arrays not processed + }; + + expect(sanitizeInputs(input)).toEqual(expected); + }); + + it('should handle null and undefined values', () => { + const input = { + name: '"John"', + value: null, + optional: undefined, + }; + + const expected = { + name: 'John', + value: null, + optional: undefined, + }; + + expect(sanitizeInputs(input)).toEqual(expected); + }); +}); + +describe('getBranch', () => { + it('should extract branch from pull request context', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'pull_request', + payload: { + pull_request: { + number: 123, + head: { + ref: 'feature-branch', + }, + }, + }, + }), + }); + + expect(getBranch(ctx)).toBe('feature-branch'); + }); + + it('should extract branch from push context', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'push', + ref: 'refs/heads/develop', + payload: { + push: { + ref: 'refs/heads/develop', + }, + }, + }), + }); + + expect(getBranch(ctx)).toBe('develop'); + }); + + it('should fall back to ref when event data is not available', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'workflow_dispatch', + ref: 'refs/heads/main', + payload: {}, + }), + }); + + expect(getBranch(ctx)).toBe('main'); + }); + + it('should return default branch when no ref is available', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'schedule', + ref: '', + payload: {}, + }), + }); + + expect(getBranch(ctx)).toBe('main'); + }); + + it('should handle pull request synchronize event', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'pull_request', + payload: { + action: 'synchronize', + pull_request: { + number: 456, + head: { + ref: 'sync-branch', + }, + }, + }, + }), + }); + + expect(getBranch(ctx)).toBe('sync-branch'); + }); + + it('should handle workflow_run event', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'workflow_run', + ref: 'refs/heads/workflow-branch', + payload: { + workflow_run: { + head_branch: 'workflow-branch', + }, + }, + }), + }); + + expect(getBranch(ctx)).toBe('workflow-branch'); + }); + + it('should handle tag refs', () => { + const ctx = createMockGitHubContext({ + context: createMockContext({ + eventName: 'push', + ref: 'refs/tags/v1.0.0', + payload: {}, + }), + }); + + expect(getBranch(ctx)).toBe('refs/tags/v1.0.0'); + }); +}); diff --git a/src/__tests__/pull-requests.test.ts b/src/__tests__/pull-requests.test.ts new file mode 100644 index 0000000..7bd7d0a --- /dev/null +++ b/src/__tests__/pull-requests.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + addLabelsToPullRequest, + findPullRequestsByLabels, + getPullRequestFiles, + pullRequestHasLabels, + removeLabelsFromPullRequest, +} from '../pull-requests.js'; +import { createSimpleMockContext } from './test-utils.js'; + +describe('findPullRequestsByLabels', () => { + it('should find pull requests matching criteria', async () => { + const mockPRs = [ + { + number: 1, + title: 'Fix bug', + labels: [{ name: 'bug' }, { name: 'urgent' }], + user: { login: 'alice' }, + }, + { + number: 2, + title: 'Add feature', + labels: [{ name: 'feature' }], + user: { login: 'bob' }, + }, + { + number: 3, + title: 'Another bug fix', + labels: [{ name: 'bug' }], + user: { login: 'alice' }, + }, + ]; + + const mockGithub = { + rest: { + pulls: { + list: vi + .fn() + .mockResolvedValueOnce({ data: mockPRs }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findPullRequestsByLabels({ + ctx, + repo, + options: { + labels: ['bug'], + state: 'open', + }, + }); + + expect(result).toHaveLength(2); + expect(result[0].number).toBe(1); + expect(result[1].number).toBe(3); + }); + + it('should handle empty results', async () => { + const mockGithub = { + rest: { + pulls: { + list: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findPullRequestsByLabels({ + ctx, + repo, + options: { labels: ['nonexistent'] }, + }); + + expect(result).toHaveLength(0); + }); + + it('should respect limit parameter', async () => { + const mockPRs = Array.from({ length: 5 }, (_, i) => ({ + number: i + 1, + title: `PR ${i + 1}`, + labels: [{ name: 'test' }], + })); + + const mockGithub = { + rest: { + pulls: { + list: vi.fn().mockResolvedValue({ data: mockPRs }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await findPullRequestsByLabels({ + ctx, + repo, + options: { + labels: ['test'], + limit: 3, + }, + }); + + expect(result).toHaveLength(3); + }); +}); + +describe('addLabelsToPullRequest', () => { + it('should add labels to pull request', async () => { + const mockGithub = { + rest: { + issues: { + addLabels: vi.fn().mockResolvedValue({ + data: [{ name: 'bug' }, { name: 'urgent' }], + }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await addLabelsToPullRequest({ + ctx, + repo, + pullNumber: 123, + labels: ['bug', 'urgent'], + }); + + expect(result).toBeUndefined(); + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + issue_number: 123, + labels: ['bug', 'urgent'], + }); + expect(ctx.core.info).toHaveBeenCalledWith('Adding labels to PR #123: bug, urgent'); + }); +}); + +describe('removeLabelsFromPullRequest', () => { + it('should remove labels from pull request', async () => { + const mockGithub = { + rest: { + issues: { + removeLabel: vi.fn().mockResolvedValue({}), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + await removeLabelsFromPullRequest({ + ctx, + repo, + pullNumber: 123, + labels: ['bug', 'urgent'], + }); + + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledTimes(2); + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + issue_number: 123, + name: 'bug', + }); + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith({ + owner: 'test', + repo: 'test-repo', + issue_number: 123, + name: 'urgent', + }); + expect(ctx.core.info).toHaveBeenCalledWith('Removing labels from PR #123: bug, urgent'); + }); + + it('should handle label removal errors gracefully', async () => { + const mockGithub = { + rest: { + issues: { + removeLabel: vi.fn().mockResolvedValueOnce({}).mockRejectedValueOnce({ status: 404 }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + ctx.core.warning = vi.fn(); + + const repo = { owner: 'test', repo: 'test-repo' }; + await removeLabelsFromPullRequest({ + ctx, + repo, + pullNumber: 123, + labels: ['existing', 'nonexistent'], + }); + + expect(ctx.core.info).toHaveBeenCalledWith("Label 'nonexistent' was not present on PR #123"); + }); +}); + +describe('pullRequestHasLabels', () => { + it('should return true when PR has all specified labels', async () => { + const mockPR = { + labels: [{ name: 'bug' }, { name: 'urgent' }, { name: 'frontend' }], + }; + + const mockGithub = { + rest: { + pulls: { + get: vi.fn().mockResolvedValue({ data: mockPR }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await pullRequestHasLabels({ + ctx, + repo, + pullNumber: 123, + labels: ['bug', 'urgent'], + }); + + expect(result).toBe(true); + }); + + it('should return false when PR is missing some labels', async () => { + const mockPR = { + labels: [{ name: 'bug' }], + }; + + const mockGithub = { + rest: { + pulls: { + get: vi.fn().mockResolvedValue({ data: mockPR }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await pullRequestHasLabels({ + ctx, + repo, + pullNumber: 123, + labels: ['bug', 'urgent'], + }); + + expect(result).toBe(false); + }); + + it('should return true for empty labels array', async () => { + const mockPR = { + labels: [{ name: 'bug' }], + }; + + const mockGithub = { + rest: { + pulls: { + get: vi.fn().mockResolvedValue({ data: mockPR }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await pullRequestHasLabels({ + ctx, + repo, + pullNumber: 123, + labels: [], + }); + + expect(result).toBe(true); + }); +}); + +describe('getPullRequestFiles', () => { + it('should return list of changed files', async () => { + const mockFiles = [ + { + filename: 'src/index.ts', + status: 'modified', + additions: 10, + deletions: 5, + changes: 15, + patch: "@@ -1,3 +1,4 @@\n console.log('test');", + }, + { + filename: 'README.md', + status: 'added', + additions: 20, + deletions: 0, + changes: 20, + patch: '@@ -0,0 +1,20 @@\n# New README', + }, + { + filename: 'old-file.js', + status: 'removed', + additions: 0, + deletions: 50, + changes: 50, + patch: undefined, + }, + ]; + + const mockGithub = { + rest: { + pulls: { + listFiles: vi + .fn() + .mockResolvedValueOnce({ data: mockFiles }) + .mockResolvedValueOnce({ data: [] }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await getPullRequestFiles({ + ctx, + repo, + pullNumber: 123, + }); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + filename: 'src/index.ts', + status: 'modified', + additions: 10, + deletions: 5, + changes: 15, + patch: "@@ -1,3 +1,4 @@\n console.log('test');", + }); + expect(result[2]).toEqual({ + filename: 'old-file.js', + status: 'removed', + additions: 0, + deletions: 50, + changes: 50, + }); + }); + + it('should handle files without patches', async () => { + const mockFiles = [ + { + filename: 'binary-file.png', + status: 'added', + additions: 0, + deletions: 0, + changes: 0, + patch: undefined, + }, + ]; + + const mockGithub = { + rest: { + pulls: { + listFiles: vi.fn().mockResolvedValue({ data: mockFiles }), + }, + }, + }; + + const ctx = createSimpleMockContext(mockGithub); + + const repo = { owner: 'test', repo: 'test-repo' }; + const result = await getPullRequestFiles({ + ctx, + repo, + pullNumber: 123, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + filename: 'binary-file.png', + status: 'added', + additions: 0, + deletions: 0, + changes: 0, + }); + expect(result[0]).not.toHaveProperty('patch'); + }); +}); diff --git a/src/__tests__/string-utils.test.ts b/src/__tests__/string-utils.test.ts new file mode 100644 index 0000000..e49bf82 --- /dev/null +++ b/src/__tests__/string-utils.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + camelToKebab, + camelToSnake, + capitalize, + kebabToCamel, + snakeToCamel, + toTitleCase, +} from '../string-utils.js'; + +describe('snakeToCamel', () => { + it('should convert snake_case to camelCase', () => { + expect(snakeToCamel('hello_world')).toBe('helloWorld'); + expect(snakeToCamel('my_variable_name')).toBe('myVariableName'); + expect(snakeToCamel('single')).toBe('single'); + expect(snakeToCamel('')).toBe(''); + }); + + it('should handle multiple underscores', () => { + expect(snakeToCamel('a_b_c_d')).toBe('aBCD'); + }); +}); + +describe('camelToSnake', () => { + it('should convert camelCase to snake_case', () => { + expect(camelToSnake('helloWorld')).toBe('hello_world'); + expect(camelToSnake('myVariableName')).toBe('my_variable_name'); + expect(camelToSnake('single')).toBe('single'); + expect(camelToSnake('')).toBe(''); + }); + + it('should handle consecutive capitals', () => { + expect(camelToSnake('XMLHttpRequest')).toBe('_x_m_l_http_request'); + }); +}); + +describe('kebabToCamel', () => { + it('should convert kebab-case to camelCase', () => { + expect(kebabToCamel('hello-world')).toBe('helloWorld'); + expect(kebabToCamel('my-variable-name')).toBe('myVariableName'); + expect(kebabToCamel('single')).toBe('single'); + expect(kebabToCamel('')).toBe(''); + }); +}); + +describe('camelToKebab', () => { + it('should convert camelCase to kebab-case', () => { + expect(camelToKebab('helloWorld')).toBe('hello-world'); + expect(camelToKebab('myVariableName')).toBe('my-variable-name'); + expect(camelToKebab('single')).toBe('single'); + expect(camelToKebab('')).toBe(''); + }); +}); + +describe('capitalize', () => { + it('should capitalize the first letter', () => { + expect(capitalize('hello')).toBe('Hello'); + expect(capitalize('world')).toBe('World'); + expect(capitalize('a')).toBe('A'); + expect(capitalize('')).toBe(''); + }); + + it('should not change already capitalized strings', () => { + expect(capitalize('Hello')).toBe('Hello'); + }); +}); + +describe('toTitleCase', () => { + it('should convert strings to Title Case', () => { + expect(toTitleCase('hello world')).toBe('Hello World'); + expect(toTitleCase('the quick brown fox')).toBe('The Quick Brown Fox'); + expect(toTitleCase('UPPERCASE TEXT')).toBe('Uppercase Text'); + expect(toTitleCase('mixed CaSe WoRdS')).toBe('Mixed Case Words'); + }); + + it('should handle single words', () => { + expect(toTitleCase('hello')).toBe('Hello'); + expect(toTitleCase('WORLD')).toBe('World'); + }); + + it('should handle empty strings', () => { + expect(toTitleCase('')).toBe(''); + }); +}); diff --git a/src/__tests__/test-utils.ts b/src/__tests__/test-utils.ts new file mode 100644 index 0000000..d8b656b --- /dev/null +++ b/src/__tests__/test-utils.ts @@ -0,0 +1,190 @@ +import { vi } from 'vitest'; +import type { GitHubContext } from '../types.js'; + +const defaultCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + notice: vi.fn(), + setOutput: vi.fn(), + setFailed: vi.fn(), + isDebug: vi.fn().mockReturnValue(false), + exportVariable: vi.fn(), + setSecret: vi.fn(), + addPath: vi.fn(), + getInput: vi.fn(), + getMultilineInput: vi.fn(), + getBooleanInput: vi.fn(), + setCommandEcho: vi.fn(), + saveState: vi.fn(), + getState: vi.fn(), + getIDToken: vi.fn(), + summary: { + addRaw: vi.fn(), + addCodeBlock: vi.fn(), + addList: vi.fn(), + addTable: vi.fn(), + addDetails: vi.fn(), + addImage: vi.fn(), + addHeading: vi.fn(), + addSeparator: vi.fn(), + addBreak: vi.fn(), + addQuote: vi.fn(), + addLink: vi.fn(), + clear: vi.fn(), + stringify: vi.fn(), + isEmptyBuffer: vi.fn(), + emptyBuffer: vi.fn(), + write: vi.fn(), + }, + markdownSummary: vi.fn(), + group: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), +}; + +const defaultGithub = { + rest: { + issues: { + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + listComments: vi.fn(), + addLabels: vi.fn(), + removeLabel: vi.fn(), + }, + pulls: { + list: vi.fn(), + get: vi.fn(), + listFiles: vi.fn(), + }, + repos: { + listBranches: vi.fn(), + getBranch: vi.fn(), + getBranchProtection: vi.fn(), + get: vi.fn(), + listDeployments: vi.fn(), + createDeployment: vi.fn(), + deleteDeployment: vi.fn(), + listDeploymentStatuses: vi.fn(), + createDeploymentStatus: vi.fn(), + }, + search: { + issuesAndPullRequests: vi.fn(), + }, + }, + graphql: vi.fn(), + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + hook: { + before: vi.fn(), + after: vi.fn(), + error: vi.fn(), + wrap: vi.fn(), + }, + auth: vi.fn(), + request: vi.fn(), + paginate: vi.fn(), +}; + +/** + * Creates a properly typed mock GitHubContext for testing + */ +export function createMockGitHubContext(overrides: Partial = {}): GitHubContext { + const defaultContext: GitHubContext['context'] = { + eventName: 'push', + ref: 'refs/heads/main', + sha: 'abc123', + workflow: 'test', + action: 'test', + actor: 'test-user', + job: 'test-job', + runNumber: 1, + runId: 1, + runAttempt: 1, + apiUrl: 'https://api.github.com', + serverUrl: 'https://github.com', + graphqlUrl: 'https://api.github.com/graphql', + repo: { owner: 'test', repo: 'test-repo' }, + issue: { owner: 'test', repo: 'test-repo', number: 1 }, + payload: {}, + }; + + return { + core: defaultCore, + github: defaultGithub, + context: defaultContext, + ...overrides, + } as unknown as GitHubContext; +} + +/** + * Creates a mock context for getBranch function tests + */ +export function createMockContext( + overrides: Partial = {} +): GitHubContext['context'] { + return { + eventName: 'push', + ref: 'refs/heads/main', + sha: 'abc123', + workflow: 'test', + action: 'test', + actor: 'test-user', + job: 'test-job', + runNumber: 1, + runId: 1, + runAttempt: 1, + apiUrl: 'https://api.github.com', + serverUrl: 'https://github.com', + graphqlUrl: 'https://api.github.com/graphql', + repo: { owner: 'test', repo: 'test-repo' }, + issue: { owner: 'test', repo: 'test-repo', number: 1 }, + payload: {}, + ...overrides, + }; +} + +/** + * Creates a simple mock GitHubContext with custom github.rest methods + * This is a simpler alternative when you just need to mock specific API calls + */ +export function createSimpleMockContext(mockGithub: Record = {}): GitHubContext { + // Use the existing defaultCore and defaultGithub but override with mockGithub + const core = { ...defaultCore }; + const github = { + ...defaultGithub, + rest: { + ...defaultGithub.rest, + ...(mockGithub.rest || {}), + }, + }; + + return { + core, + github, + context: { + eventName: 'push', + ref: 'refs/heads/main', + sha: 'abc123', + workflow: 'test', + action: 'test', + actor: 'test-user', + job: 'test-job', + runNumber: 1, + runId: 1, + runAttempt: 1, + apiUrl: 'https://api.github.com', + serverUrl: 'https://github.com', + graphqlUrl: 'https://api.github.com/graphql', + repo: { owner: 'test', repo: 'test-repo' }, + issue: { owner: 'test', repo: 'test-repo', number: 1 }, + payload: {}, + }, + } as unknown as GitHubContext; +} diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 0000000..b301149 --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,471 @@ +import { describe, expect, it } from 'vitest'; +import { + codeBlock, + createMarkdownTable, + delay, + escapeMarkdown, + formatDate, + getCurrentBranch, + getCurrentIssueNumber, + getCurrentPullRequestNumber, + getCurrentSHA, + getIssueUrl, + getPullRequestUrl, + getRepoInfo, + getRepositoryUrl, + isIssueContext, + isPullRequestContext, + parseGitHubDate, + truncateText, +} from '../utils.js'; +import { createSimpleMockContext } from './test-utils.js'; + +describe('truncateText', () => { + it('should truncate text longer than maxLength', () => { + expect(truncateText('Hello, World!', 10)).toBe('Hello, ...'); + expect(truncateText('Short', 10)).toBe('Short'); + expect(truncateText('Exactly10!', 10)).toBe('Exactly10!'); + }); + + it('should handle edge cases', () => { + expect(truncateText('', 5)).toBe(''); + expect(truncateText('Hi', 2)).toBe('Hi'); + expect(truncateText('Hi', 1)).toBe('...'); + }); +}); + +describe('escapeMarkdown', () => { + it('should escape markdown special characters', () => { + expect(escapeMarkdown('*bold*')).toBe('\\*bold\\*'); + expect(escapeMarkdown('_italic_')).toBe('\\_italic\\_'); + expect(escapeMarkdown('`code`')).toBe('\\`code\\`'); + expect(escapeMarkdown('[link](url)')).toBe('\\[link\\]\\(url\\)'); + }); + + it('should handle text without special characters', () => { + expect(escapeMarkdown('plain text')).toBe('plain text'); + }); +}); + +describe('codeBlock', () => { + it('should create code blocks with language', () => { + const result = codeBlock('console.log("hello");', 'javascript'); + expect(result).toBe('```javascript\nconsole.log("hello");\n```'); + }); + + it('should create code blocks without language', () => { + const result = codeBlock('some code'); + expect(result).toBe('```\nsome code\n```'); + }); +}); + +describe('createMarkdownTable', () => { + it('should create a markdown table', () => { + const headers = ['Name', 'Age', 'City']; + const rows = [ + ['John', '30', 'New York'], + ['Jane', '25', 'Los Angeles'], + ]; + + const expected = [ + '| Name | Age | City |', + '| --- | --- | --- |', + '| John | 30 | New York |', + '| Jane | 25 | Los Angeles |', + ].join('\n'); + + expect(createMarkdownTable(headers, rows)).toBe(expected); + }); + + it('should handle empty rows', () => { + const headers = ['Column1', 'Column2']; + const rows: string[][] = []; + + const expected = ['| Column1 | Column2 |', '| --- | --- |'].join('\n'); + + expect(createMarkdownTable(headers, rows)).toBe(expected); + }); +}); + +describe('formatDate', () => { + it('should format date to ISO string', () => { + const date = new Date('2024-01-15T10:30:00Z'); + expect(formatDate(date)).toBe('2024-01-15T10:30:00.000Z'); + }); +}); + +describe('parseGitHubDate', () => { + it('should parse GitHub date string', () => { + const dateString = '2024-01-15T10:30:00Z'; + const parsed = parseGitHubDate(dateString); + expect(parsed).toBeInstanceOf(Date); + expect(parsed.getFullYear()).toBe(2024); + expect(parsed.getMonth()).toBe(0); // January is 0 + expect(parsed.getDate()).toBe(15); + }); +}); + +describe('getRepoInfo', () => { + it('should extract repository info from context', () => { + const context = { + repo: { + owner: 'test-owner', + repo: 'test-repo', + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getRepoInfo(ctx); + expect(result).toEqual({ + owner: 'test-owner', + repo: 'test-repo', + }); + }); +}); + +describe('getCurrentPullRequestNumber', () => { + it('should return PR number from pull_request event', () => { + const context = { + eventName: 'pull_request', + payload: { + pull_request: { + number: 123, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentPullRequestNumber(ctx); + expect(result).toBe(123); + }); + + it('should return null for issue comment on PR (function only checks pull_request payload)', () => { + const context = { + eventName: 'issue_comment', + payload: { + issue: { + number: 456, + pull_request: {}, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentPullRequestNumber(ctx); + expect(result).toBeNull(); + }); + + it('should return null for non-PR events', () => { + const context = { + eventName: 'push', + payload: {}, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentPullRequestNumber(ctx); + expect(result).toBeNull(); + }); +}); + +describe('getCurrentIssueNumber', () => { + it('should return issue number from issues event', () => { + const context = { + eventName: 'issues', + payload: { + issue: { + number: 789, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentIssueNumber(ctx); + expect(result).toBe(789); + }); + + it('should return issue number from issue comment', () => { + const context = { + eventName: 'issue_comment', + payload: { + issue: { + number: 101, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentIssueNumber(ctx); + expect(result).toBe(101); + }); + + it('should return null for non-issue events', () => { + const context = { + eventName: 'push', + payload: {}, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentIssueNumber(ctx); + expect(result).toBeNull(); + }); +}); + +describe('isPullRequestContext', () => { + it('should return true for pull_request event', () => { + const context = { + eventName: 'pull_request', + payload: { + pull_request: {}, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + expect(isPullRequestContext(ctx)).toBe(true); + }); + + it('should return false for issue_comment (only checks pull_request payload)', () => { + const context = { + eventName: 'issue_comment', + payload: { + issue: { + pull_request: {}, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + expect(isPullRequestContext(ctx)).toBe(false); + }); + + it('should return false for regular issue', () => { + const context = { + eventName: 'issues', + payload: { + issue: {}, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + expect(isPullRequestContext(ctx)).toBe(false); + }); +}); + +describe('isIssueContext', () => { + it('should return true for issues event', () => { + const context = { + eventName: 'issues', + payload: { + issue: {}, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + expect(isIssueContext(ctx)).toBe(true); + }); + + it('should return true for issue_comment', () => { + const context = { + eventName: 'issue_comment', + payload: { + issue: {}, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + expect(isIssueContext(ctx)).toBe(true); + }); + + it('should return false for non-issue events', () => { + const context = { + eventName: 'push', + payload: {}, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + expect(isIssueContext(ctx)).toBe(false); + }); +}); + +describe('getCurrentSHA', () => { + it('should return SHA from pull_request event', () => { + const context = { + eventName: 'pull_request', + payload: { + pull_request: { + head: { + sha: 'abc123', + }, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentSHA(ctx); + expect(result).toBe('abc123'); + }); + + it('should return SHA from context.sha', () => { + const context = { + eventName: 'push', + sha: 'def456', + payload: {}, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentSHA(ctx); + expect(result).toBe('def456'); + }); + + it('should return undefined when SHA not available', () => { + const context = { + eventName: 'issues', + payload: {}, + sha: undefined, // Explicitly set to undefined + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentSHA(ctx); + expect(result).toBeUndefined(); + }); +}); + +describe('getCurrentBranch', () => { + it('should return branch from pull_request event', () => { + const context = { + eventName: 'pull_request', + payload: { + pull_request: { + head: { + ref: 'feature-branch', + }, + }, + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentBranch(ctx); + expect(result).toBe('feature-branch'); + }); + + it('should return branch from push event', () => { + const context = { + eventName: 'push', + ref: 'refs/heads/main', + payload: {}, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentBranch(ctx); + expect(result).toBe('main'); + }); + + it('should return ref when branch not available in standard format', () => { + const context = { + eventName: 'issues', + payload: {}, + ref: 'some-ref', + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getCurrentBranch(ctx); + expect(result).toBe('some-ref'); + }); +}); + +describe('getRepositoryUrl', () => { + it('should return repository URL', () => { + const context = { + repo: { + owner: 'test-owner', + repo: 'test-repo', + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getRepositoryUrl(ctx); + expect(result).toBe('https://github.com/test-owner/test-repo'); + }); +}); + +describe('getIssueUrl', () => { + it('should return issue URL', () => { + const context = { + repo: { + owner: 'test-owner', + repo: 'test-repo', + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getIssueUrl(ctx, 123); + expect(result).toBe('https://github.com/test-owner/test-repo/issues/123'); + }); +}); + +describe('getPullRequestUrl', () => { + it('should return pull request URL', () => { + const context = { + repo: { + owner: 'test-owner', + repo: 'test-repo', + }, + }; + + const ctx = createSimpleMockContext(); + Object.assign(ctx.context, context); + + const result = getPullRequestUrl(ctx, 456); + expect(result).toBe('https://github.com/test-owner/test-repo/pull/456'); + }); +}); + +describe('delay', () => { + it('should delay execution', async () => { + const start = Date.now(); + await delay(100); + const end = Date.now(); + expect(end - start).toBeGreaterThanOrEqual(90); // Allow some variance + }); +}); diff --git a/src/advanced-pr-utils.ts b/src/advanced-pr-utils.ts new file mode 100644 index 0000000..8cbc4fc --- /dev/null +++ b/src/advanced-pr-utils.ts @@ -0,0 +1,239 @@ +import type { GitHubContext, PullRequest, RepoInfo } from './types.js'; + +/** + * Options for searching pull requests + */ +export type AdvancedPRSearchOptions = { + /** + * Labels that must be present on the PR (AND logic) + */ + labels?: string[]; + /** + * PR state to filter by + * @default 'open' + */ + state?: 'open' | 'closed' | 'all'; + /** + * Author username to filter by + */ + author?: string; + /** + * Maximum number of PRs to return + * @default 100 + */ + limit?: number; + /** + * Sort order + * @default 'created' + */ + sort?: 'created' | 'updated' | 'popularity' | 'long-running'; + /** + * Sort direction + * @default 'desc' + */ + direction?: 'asc' | 'desc'; + /** + * Exclude specific PR numbers + */ + excludePRs?: number[]; +}; + +/** + * Finds pull requests with specific labels (all labels must be present) + */ +export async function findPRsWithLabels({ + ctx, + repo, + labels, + state = 'open', + limit = 100, + excludePRs = [], +}: { + ctx: GitHubContext; + repo: RepoInfo; + labels: string[]; + state?: 'open' | 'closed' | 'all'; + limit?: number; + excludePRs?: number[]; +}): Promise { + if (labels.length === 0) { + throw new Error('At least one label must be specified'); + } + + ctx.core.info(`Searching for PRs with labels: ${labels.join(', ')}`); + + const pullRequests: PullRequest[] = []; + let page = 1; + const perPage = Math.min(limit * 2, 100); // Get more than needed for filtering + + while (pullRequests.length < limit) { + const { data } = await ctx.github.rest.pulls.list({ + ...repo, + state, + per_page: perPage, + page, + }); + + if (data.length === 0) { + break; + } + + // Filter PRs that have ALL specified labels and aren't excluded + const matchingPRs = data.filter((pr) => { + if (excludePRs.includes(pr.number)) { + return false; + } + + const prLabels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name)); + return labels.every((requiredLabel) => prLabels.includes(requiredLabel)); + }); + + pullRequests.push(...(matchingPRs as PullRequest[])); + + if (data.length < perPage || pullRequests.length >= limit) { + break; + } + + page++; + } + + ctx.core.info(`Found ${pullRequests.length} PRs matching label criteria`); + return pullRequests.slice(0, limit); +} + +/** + * Advanced pull request search with multiple criteria + */ +export async function searchPullRequests({ + ctx, + repo, + options, +}: { + ctx: GitHubContext; + repo: RepoInfo; + options: AdvancedPRSearchOptions; +}): Promise { + const { + labels = [], + state = 'open', + author, + limit = 100, + sort = 'created', + direction = 'desc', + excludePRs = [], + } = options; + + const pullRequests: PullRequest[] = []; + let page = 1; + const perPage = Math.min(limit * 2, 100); + + while (pullRequests.length < limit) { + const { data } = await ctx.github.rest.pulls.list({ + ...repo, + state, + sort, + direction, + per_page: perPage, + page, + }); + + if (data.length === 0) { + break; + } + + // Apply filters + const filteredPRs = data.filter((pr) => { + // Exclude specific PRs + if (excludePRs.includes(pr.number)) { + return false; + } + + // Filter by author + if (author && pr.user?.login !== author) { + return false; + } + + // Filter by labels (if specified) + if (labels.length > 0) { + const prLabels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name)); + if (!labels.every((requiredLabel) => prLabels.includes(requiredLabel))) { + return false; + } + } + + return true; + }); + + pullRequests.push(...(filteredPRs as PullRequest[])); + + if (data.length < perPage || pullRequests.length >= limit) { + break; + } + + page++; + } + + return pullRequests.slice(0, limit); +} + +/** + * Finds open PRs with a specific label (convenience function) + */ +export async function findOpenPRsWithLabel({ + ctx, + repo, + label, + excludePRs = [], + limit = 100, +}: { + ctx: GitHubContext; + repo: RepoInfo; + label: string; + excludePRs?: number[]; + limit?: number; +}): Promise { + return findPRsWithLabels({ + ctx, + repo, + labels: [label], + state: 'open', + excludePRs, + limit, + }); +} + +/** + * Checks if a label is already in use by other open PRs + */ +export async function checkLabelConflicts({ + ctx, + repo, + prNumber, + label, +}: { + ctx: GitHubContext; + repo: RepoInfo; + prNumber: number; + label: string; +}): Promise<{ hasConflict: boolean; conflictingPR?: PullRequest }> { + const conflictingPRs = await findOpenPRsWithLabel({ + ctx, + repo, + label, + excludePRs: [prNumber], + limit: 1, + }); + + if (conflictingPRs.length > 0) { + const conflictingPR = conflictingPRs[0]; + + if (conflictingPR) { + return { + hasConflict: true, + conflictingPR, + }; + } + } + + return { hasConflict: false }; +} diff --git a/src/branch-utils.ts b/src/branch-utils.ts new file mode 100644 index 0000000..6f0f5db --- /dev/null +++ b/src/branch-utils.ts @@ -0,0 +1,106 @@ +import type { GitHubContext, RepoInfo } from './types.js'; + +/** + * Checks if a branch exists in the repository + */ +export async function checkBranchExists({ + ctx, + repo, + branch, +}: { + ctx: GitHubContext; + repo: RepoInfo; + branch: string; +}): Promise { + try { + await ctx.github.rest.repos.getBranch({ + ...repo, + branch, + }); + return true; + } catch (error: unknown) { + if (error && typeof error === 'object' && 'status' in error && error.status === 404) { + return false; + } + throw error; + } +} + +/** + * Lists all branches in the repository + */ +export async function listAllBranches({ + ctx, + repo, + limit = 100, +}: { + ctx: GitHubContext; + repo: RepoInfo; + limit?: number; +}): Promise { + const branches: string[] = []; + let page = 1; + const perPage = Math.min(limit, 100); + + while (branches.length < limit) { + const { data } = await ctx.github.rest.repos.listBranches({ + ...repo, + per_page: perPage, + page, + }); + + if (data.length === 0) { + break; + } + + branches.push(...data.map((branch) => branch.name)); + + if (data.length < perPage) { + break; // No more pages + } + + page++; + } + + return branches.slice(0, limit); +} + +/** + * Gets branch protection rules for a branch + */ +export async function getBranchProtection({ + ctx, + repo, + branch, +}: { + ctx: GitHubContext; + repo: RepoInfo; + branch: string; +}) { + try { + const { data } = await ctx.github.rest.repos.getBranchProtection({ + ...repo, + branch, + }); + return data; + } catch (error: unknown) { + if (error && typeof error === 'object' && 'status' in error && error.status === 404) { + return null; // No protection rules + } + throw error; + } +} + +/** + * Gets the default branch of the repository + */ +export async function getDefaultBranch({ + ctx, + repo, +}: { + ctx: GitHubContext; + repo: RepoInfo; +}): Promise { + const { data } = await ctx.github.rest.repos.get(repo); + return data.default_branch; +} diff --git a/src/comments.ts b/src/comments.ts index 4d57d18..37d812b 100644 --- a/src/comments.ts +++ b/src/comments.ts @@ -1,10 +1,10 @@ import type { + CommentSearchOptions, GitHubContext, - RepoInfo, IssueComment, + RepoInfo, StickyCommentOptions, - CommentSearchOptions, -} from "./types.js"; +} from './types.js'; /** * Creates or updates a "sticky" comment on an issue or pull request. @@ -38,16 +38,13 @@ export async function createStickyComment({ }); if (existingComment) { - ctx.core.info( - `Updating existing sticky comment ${identifier} (ID: ${existingComment.id})` - ); + ctx.core.info(`Updating existing sticky comment ${identifier} (ID: ${existingComment.id})`); - const { data: updatedComment } = - await ctx.github.rest.issues.updateComment({ - ...repo, - comment_id: existingComment.id, - body: fullBody, - }); + const { data: updatedComment } = await ctx.github.rest.issues.updateComment({ + ...repo, + comment_id: existingComment.id, + body: fullBody, + }); return updatedComment as IssueComment; } @@ -88,9 +85,7 @@ export async function findCommentByIdentifier({ }); // Find comment containing our identifier - const matchingComment = comments.find((comment: any) => - comment.body?.includes(identifierMarker) - ); + const matchingComment = comments.find((comment) => comment.body?.includes(identifierMarker)); return (matchingComment as IssueComment) || null; } @@ -109,13 +104,7 @@ export async function searchComments({ issueNumber: number; options: CommentSearchOptions; }): Promise { - const { - bodyContains, - author, - createdAfter, - createdBefore, - limit = 100, - } = options; + const { bodyContains, author, createdAfter, createdBefore, limit = 100 } = options; const { data: allComments } = await ctx.github.rest.issues.listComments({ ...repo, @@ -134,9 +123,7 @@ export async function searchComments({ // Filter by author if (author) { - filteredComments = filteredComments.filter( - (comment) => comment.user?.login === author - ); + filteredComments = filteredComments.filter((comment) => comment.user?.login === author); } // Filter by creation date diff --git a/src/deployment-utils.ts b/src/deployment-utils.ts new file mode 100644 index 0000000..1b48b3c --- /dev/null +++ b/src/deployment-utils.ts @@ -0,0 +1,200 @@ +import type { GitHubContext, RepoInfo } from './types.js'; + +/** + * Deployment structure from GitHub API + */ +export type Deployment = { + id: number; + sha: string; + ref: string; + task: string; + payload: Record; + environment: string; + description: string | null; + creator: { + login: string; + id: number; + } | null; + created_at: string; + updated_at: string; + statuses_url: string; + repository_url: string; +}; + +/** + * Deployment status structure + */ +export type DeploymentStatus = { + id: number; + state: 'error' | 'failure' | 'inactive' | 'pending' | 'success' | 'queued' | 'in_progress'; + creator: { + login: string; + id: number; + } | null; + description: string | null; + environment: string; + target_url: string | null; + created_at: string; + updated_at: string; + deployment_url: string; + repository_url: string; +}; + +/** + * Lists deployments for a repository or specific ref + */ +export async function listDeployments({ + ctx, + repo, + ref, + environment, + limit = 100, +}: { + ctx: GitHubContext; + repo: RepoInfo; + ref?: string; + environment?: string; + limit?: number; +}): Promise { + const deployments: Deployment[] = []; + let page = 1; + const perPage = Math.min(limit, 100); + + while (deployments.length < limit) { + const { data } = await ctx.github.rest.repos.listDeployments({ + ...repo, + ...(ref && { ref }), + ...(environment && { environment }), + per_page: perPage, + page, + }); + + if (data.length === 0) { + break; + } + + deployments.push(...(data as Deployment[])); + + if (data.length < perPage) { + break; + } + + page++; + } + + return deployments.slice(0, limit); +} + +/** + * Gets deployment statuses for a specific deployment + */ +export async function getDeploymentStatuses({ + ctx, + repo, + deploymentId, +}: { + ctx: GitHubContext; + repo: RepoInfo; + deploymentId: number; +}): Promise { + const { data } = await ctx.github.rest.repos.listDeploymentStatuses({ + ...repo, + deployment_id: deploymentId, + }); + + return data as DeploymentStatus[]; +} + +/** + * Creates a deployment status + */ +export async function setDeploymentStatus({ + ctx, + repo, + deploymentId, + state, + description, + targetUrl, + environment, +}: { + ctx: GitHubContext; + repo: RepoInfo; + deploymentId: number; + state: 'error' | 'failure' | 'inactive' | 'pending' | 'success' | 'queued' | 'in_progress'; + description?: string; + targetUrl?: string; + environment?: string; +}): Promise { + const { data } = await ctx.github.rest.repos.createDeploymentStatus({ + ...repo, + deployment_id: deploymentId, + state, + ...(description && { description }), + ...(targetUrl && { target_url: targetUrl }), + ...(environment && { environment }), + }); + + return data as DeploymentStatus; +} + +/** + * Deletes a deployment + */ +export async function deleteDeployment({ + ctx, + repo, + deploymentId, +}: { + ctx: GitHubContext; + repo: RepoInfo; + deploymentId: number; +}): Promise { + // First set deployment to inactive + await setDeploymentStatus({ + ctx, + repo, + deploymentId, + state: 'inactive', + }); + + // Then delete the deployment + await ctx.github.rest.repos.deleteDeployment({ + ...repo, + deployment_id: deploymentId, + }); +} + +/** + * Creates a new deployment + */ +export async function createDeployment({ + ctx, + repo, + ref, + environment, + description, + payload, + autoMerge = true, + requiredContexts, +}: { + ctx: GitHubContext; + repo: RepoInfo; + ref: string; + environment: string; + description?: string; + payload?: Record; + autoMerge?: boolean; + requiredContexts?: string[]; +}): Promise { + const { data } = await ctx.github.rest.repos.createDeployment({ + ...repo, + ref, + environment, + auto_merge: autoMerge, + ...(description && { description }), + ...(payload && { payload }), + ...(requiredContexts && { required_contexts: requiredContexts }), + }); + + return data as Deployment; +} diff --git a/src/index.ts b/src/index.ts index af4c788..de2c77c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,45 +5,79 @@ * the github-typescript composite action. */ -// Export all types -export type * from "./types.js"; - +// Export advanced PR utilities +export { + type AdvancedPRSearchOptions, + checkLabelConflicts, + findOpenPRsWithLabel, + findPRsWithLabels, + searchPullRequests, +} from './advanced-pr-utils.js'; +// Export branch utilities +export { + checkBranchExists, + getBranchProtection, + getDefaultBranch, + listAllBranches, +} from './branch-utils.js'; // Export comment utilities export { createStickyComment, - findCommentByIdentifier, - searchComments, deleteComment, deleteStickyComment, -} from "./comments.js"; + findCommentByIdentifier, + searchComments, +} from './comments.js'; +// Export deployment utilities +export { + createDeployment, + type Deployment, + type DeploymentStatus, + deleteDeployment, + getDeploymentStatuses, + listDeployments, + setDeploymentStatus, +} from './deployment-utils.js'; +// Export input utilities +export { getBranch, sanitizeInput, sanitizeInputs } from './input-utils.js'; // Export pull request utilities export { + addLabelsToPullRequest, findPullRequestsByLabels, getPullRequest, - addLabelsToPullRequest, - removeLabelsFromPullRequest, - pullRequestHasLabels, getPullRequestFiles, -} from "./pull-requests.js"; - + pullRequestHasLabels, + removeLabelsFromPullRequest, +} from './pull-requests.js'; +// Export string utilities +export { + camelToKebab, + camelToSnake, + capitalize, + kebabToCamel, + snakeToCamel, + toTitleCase, +} from './string-utils.js'; +// Export all types +export type * from './types.js'; // Export general utilities export { - getRepoInfo, - getCurrentPullRequestNumber, + codeBlock, + createMarkdownTable, + delay, + escapeMarkdown, + formatDate, + getCurrentBranch, getCurrentIssueNumber, - isPullRequestContext, - isIssueContext, + getCurrentPullRequestNumber, getCurrentSHA, - getCurrentBranch, - getRepositoryUrl, getIssueUrl, getPullRequestUrl, - formatDate, + getRepoInfo, + getRepositoryUrl, + isIssueContext, + isPullRequestContext, parseGitHubDate, - delay, truncateText, - escapeMarkdown, - codeBlock, - createMarkdownTable, -} from "./utils.js"; +} from './utils.js'; diff --git a/src/input-utils.ts b/src/input-utils.ts new file mode 100644 index 0000000..3e9a37d --- /dev/null +++ b/src/input-utils.ts @@ -0,0 +1,62 @@ +import type { GitHubContext } from './types.js'; + +/** + * Sanitizes input by removing leading and trailing quotes. + * This is useful as inputs may have extra quotes added by steps in the workflow. + */ +export function sanitizeInput(input: string): string; +export function sanitizeInput(input: unknown): unknown; +export function sanitizeInput(input: unknown): unknown { + if (typeof input === 'string') { + return input.replace(/^"|"$/g, ''); + } + return input; +} + +/** + * Sanitizes all string values in an input object by removing leading and trailing quotes. + * This is useful as inputs may have extra quotes added by steps in the workflow. + */ +export function sanitizeInputs>(obj: T): T { + const sanitizedObj: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + sanitizedObj[key] = value.replace(/^"|"$/g, ''); + } else { + sanitizedObj[key] = value; + } + } + return sanitizedObj as T; +} + +/** + * Extracts the branch name from various GitHub event types + */ +export function getBranch(ctx: GitHubContext): string { + const { context } = ctx; + const { payload, eventName, ref } = context; + + // Try to get branch from event-specific payload + const eventData = payload[eventName as keyof typeof payload]; + const branchFromEvent = eventData?.head?.ref; + + if (branchFromEvent) { + return branchFromEvent; + } + + // Fall back to ref, removing refs/heads/ prefix + if (ref?.startsWith('refs/heads/')) { + return ref.replace('refs/heads/', ''); + } + + // Last resort: try common payload locations + if (payload.pull_request?.head?.ref) { + return payload.pull_request.head.ref; + } + + if (payload.push?.ref) { + return payload.push.ref.replace('refs/heads/', ''); + } + + return ref || 'main'; +} diff --git a/src/pull-requests.ts b/src/pull-requests.ts index db9c3f0..ae051dd 100644 --- a/src/pull-requests.ts +++ b/src/pull-requests.ts @@ -1,9 +1,4 @@ -import type { - GitHubContext, - RepoInfo, - PullRequest, - PullRequestSearchOptions, -} from "./types.js"; +import type { GitHubContext, PullRequest, PullRequestSearchOptions, RepoInfo } from './types.js'; /** * Finds pull requests that have all of the specified labels @@ -17,19 +12,13 @@ export async function findPullRequestsByLabels({ repo: RepoInfo; options: PullRequestSearchOptions; }): Promise { - const { - labels, - state = "open", - limit = 30, - sort = "created", - direction = "desc", - } = options; + const { labels, state = 'open', limit = 30, sort = 'created', direction = 'desc' } = options; if (labels.length === 0) { - throw new Error("At least one label must be specified"); + throw new Error('At least one label must be specified'); } - ctx.core.info(`Searching for PRs with labels: ${labels.join(", ")}`); + ctx.core.info(`Searching for PRs with labels: ${labels.join(', ')}`); // Get pull requests with the specified state const { data: pullRequests } = await ctx.github.rest.pulls.list({ @@ -42,9 +31,7 @@ export async function findPullRequestsByLabels({ // Filter PRs that have ALL specified labels const matchingPRs = pullRequests.filter((pr) => { - const prLabels = pr.labels.map((label) => - typeof label === "string" ? label : label.name - ); + const prLabels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name)); return labels.every((requiredLabel) => prLabels.includes(requiredLabel)); }); @@ -89,11 +76,11 @@ export async function addLabelsToPullRequest({ labels: string[]; }): Promise { if (labels.length === 0) { - ctx.core.info("No labels to add"); + ctx.core.info('No labels to add'); return; } - ctx.core.info(`Adding labels to PR #${pullNumber}: ${labels.join(", ")}`); + ctx.core.info(`Adding labels to PR #${pullNumber}: ${labels.join(', ')}`); await ctx.github.rest.issues.addLabels({ ...repo, @@ -117,11 +104,11 @@ export async function removeLabelsFromPullRequest({ labels: string[]; }): Promise { if (labels.length === 0) { - ctx.core.info("No labels to remove"); + ctx.core.info('No labels to remove'); return; } - ctx.core.info(`Removing labels from PR #${pullNumber}: ${labels.join(", ")}`); + ctx.core.info(`Removing labels from PR #${pullNumber}: ${labels.join(', ')}`); // Remove each label individually for (const label of labels) { @@ -131,9 +118,9 @@ export async function removeLabelsFromPullRequest({ issue_number: pullNumber, name: label, }); - } catch (error: any) { + } catch (error: unknown) { // Ignore 404 errors (label not found on PR) - if (error?.status === 404) { + if (error && typeof error === 'object' && 'status' in error && error.status === 404) { ctx.core.info(`Label '${label}' was not present on PR #${pullNumber}`); } else { throw error; @@ -203,6 +190,6 @@ export async function getPullRequestFiles({ additions: file.additions, deletions: file.deletions, changes: file.changes, - patch: file.patch, + ...(file.patch && { patch: file.patch }), })); } diff --git a/src/string-utils.ts b/src/string-utils.ts new file mode 100644 index 0000000..8045010 --- /dev/null +++ b/src/string-utils.ts @@ -0,0 +1,41 @@ +/** + * Converts snake_case strings to camelCase + */ +export function snakeToCamel(str: string): string { + return str.replace(/(_\w)/g, (match) => match[1]?.toUpperCase() || ''); +} + +/** + * Converts camelCase strings to snake_case + */ +export function camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +} + +/** + * Converts kebab-case strings to camelCase + */ +export function kebabToCamel(str: string): string { + return str.replace(/(-\w)/g, (match) => match[1]?.toUpperCase() || ''); +} + +/** + * Converts camelCase strings to kebab-case + */ +export function camelToKebab(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} + +/** + * Capitalizes the first letter of a string + */ +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Converts a string to Title Case + */ +export function toTitleCase(str: string): string { + return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()); +} diff --git a/src/types.ts b/src/types.ts index a45cb6c..e4c1962 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ -import type { getOctokit } from "@actions/github"; -import type * as core from "@actions/core"; +import type * as core from '@actions/core'; +import type { getOctokit } from '@actions/github'; /** * Core context types provided by github-typescript wrapper @@ -7,7 +7,7 @@ import type * as core from "@actions/core"; export type GitHubContext = { core: typeof core; github: ReturnType; - context: typeof import("@actions/github").context; + context: typeof import('@actions/github').context; }; /** @@ -63,7 +63,7 @@ export type PullRequest = { number: number; title: string; body: string | null; - state: "open" | "closed"; + state: 'open' | 'closed'; draft: boolean; user: { login: string; @@ -158,7 +158,7 @@ export type PullRequestSearchOptions = { * PR state to filter by * @default 'open' */ - state?: "open" | "closed" | "all"; + state?: 'open' | 'closed' | 'all'; /** * Maximum number of PRs to return * @default 30 @@ -168,10 +168,10 @@ export type PullRequestSearchOptions = { * Sort order * @default 'created' */ - sort?: "created" | "updated" | "popularity" | "long-running"; + sort?: 'created' | 'updated' | 'popularity' | 'long-running'; /** * Sort direction * @default 'desc' */ - direction?: "asc" | "desc"; + direction?: 'asc' | 'desc'; }; diff --git a/src/utils.ts b/src/utils.ts index 169a3e3..5d7ca89 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import type { GitHubContext, RepoInfo } from "./types.js"; +import type { GitHubContext, RepoInfo } from './types.js'; /** * Extracts repository information from the GitHub context @@ -15,7 +15,7 @@ export function getRepoInfo(ctx: GitHubContext): RepoInfo { */ export function getCurrentPullRequestNumber(ctx: GitHubContext): number | null { const prNumber = ctx.context.payload.pull_request?.number; - return typeof prNumber === "number" ? prNumber : null; + return typeof prNumber === 'number' ? prNumber : null; } /** @@ -23,10 +23,8 @@ export function getCurrentPullRequestNumber(ctx: GitHubContext): number | null { */ export function getCurrentIssueNumber(ctx: GitHubContext): number | null { // Check for issue or pull request (PRs are also issues in GitHub API) - const issueNumber = - ctx.context.payload.issue?.number || - ctx.context.payload.pull_request?.number; - return typeof issueNumber === "number" ? issueNumber : null; + const issueNumber = ctx.context.payload.issue?.number || ctx.context.payload.pull_request?.number; + return typeof issueNumber === 'number' ? issueNumber : null; } /** @@ -47,11 +45,7 @@ export function isIssueContext(ctx: GitHubContext): boolean { * Gets the SHA of the current commit */ export function getCurrentSHA(ctx: GitHubContext): string { - return ( - ctx.context.payload.pull_request?.head.sha || - ctx.context.payload.after || - ctx.context.sha - ); + return ctx.context.payload.pull_request?.head.sha || ctx.context.payload.after || ctx.context.sha; } /** @@ -64,8 +58,8 @@ export function getCurrentBranch(ctx: GitHubContext): string { } // For push events, extract from ref - if (ctx.context.ref.startsWith("refs/heads/")) { - return ctx.context.ref.replace("refs/heads/", ""); + if (ctx.context.ref.startsWith('refs/heads/')) { + return ctx.context.ref.replace('refs/heads/', ''); } return ctx.context.ref; @@ -90,10 +84,7 @@ export function getIssueUrl(ctx: GitHubContext, issueNumber: number): string { /** * Creates a GitHub API URL for a specific pull request */ -export function getPullRequestUrl( - ctx: GitHubContext, - pullNumber: number -): string { +export function getPullRequestUrl(ctx: GitHubContext, pullNumber: number): string { const { owner, repo } = getRepoInfo(ctx); return `https://github.com/${owner}/${repo}/pull/${pullNumber}`; } @@ -127,34 +118,31 @@ export function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } - return text.slice(0, maxLength - 3) + "..."; + return `${text.slice(0, maxLength - 3)}...`; } /** * Escapes markdown special characters in text */ export function escapeMarkdown(text: string): string { - return text.replace(/[\\`*_{}[\]()#+\-.!]/g, "\\$&"); + return text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&'); } /** * Creates a markdown code block with optional language */ export function codeBlock(code: string, language?: string): string { - const lang = language || ""; + const lang = language || ''; return `\`\`\`${lang}\n${code}\n\`\`\``; } /** * Creates a markdown table from data */ -export function createMarkdownTable( - headers: string[], - rows: string[][] -): string { - const headerRow = `| ${headers.join(" | ")} |`; - const separatorRow = `| ${headers.map(() => "---").join(" | ")} |`; - const dataRows = rows.map((row) => `| ${row.join(" | ")} |`); +export function createMarkdownTable(headers: string[], rows: string[][]): string { + const headerRow = `| ${headers.join(' | ')} |`; + const separatorRow = `| ${headers.map(() => '---').join(' | ')} |`; + const dataRows = rows.map((row) => `| ${row.join(' | ')} |`); - return [headerRow, separatorRow, ...dataRows].join("\n"); + return [headerRow, separatorRow, ...dataRows].join('\n'); } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..99b2c09 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json", "json-summary", "html", "lcov"], + exclude: [ + "node_modules/", + "dist/", + "**/*.d.ts", + "**/*.config.*", + "coverage/", + "src/__tests__/**", + ".github/**", + "src/index.ts", + "src/types.ts", + ], + }, + }, +});