diff --git a/README.md b/README.md index 8b2f385..d51527b 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,21 @@ This is a monorepo containing a collection of GitHub Actions maintained by Lizar ## actions -| Action | Description | Type | Language | -|---------------------------------------------------------------|----------------------------------------------|-----------|------------------| -| [audit_repos](actions/audit_repos#readme) | Audit repositories in an organization | composite | javascript | -| [facebook_post](actions/facebook_post#readme) | Post to Facebook page/group using Graph API | docker | python | -| [monitor_space](actions/monitor_space#readme) | Monitor and track minimum free disk space | composite | bash | -| [more_space](actions/more_space#readme) | Free up disk space in GitHub Actions runners | composite | bash | -| [release_changelog](actions/release_changelog#readme) | Generate a changelog for the latest release | composite | javascript | -| [release_create](actions/release_create#readme) | Create a new release | composite | bash, javascript | -| [release_homebrew](actions/release_homebrew#readme) | Validate and update Homebrew formula | composite | bash, python | -| [release_setup](actions/release_setup#readme) | Prepare a release | docker | python | -| [screenshot](actions/screenshot#readme) | Setup cross-platform screenshot CLI tool | composite | bash | -| [setup_cuda](actions/setup_cuda#readme) | Set up NVIDIA CUDA Toolkit on Linux runners | composite | bash | -| [setup_python](actions/setup_python#readme) | Set up Python environment | composite | bash | -| [setup_virtual_desktop](actions/setup_virtual_desktop#readme) | Setup virtual desktop for GUI apps on Linux | composite | bash | +| Action | Description | Type | Language | +|---------------------------------------------------------------|-------------------------------------------------------------------------------|-----------|------------------| +| [audit_repos](actions/audit_repos#readme) | Audit repositories in an organization | composite | javascript | +| [facebook_post](actions/facebook_post#readme) | Post to Facebook page/group using Graph API | docker | python | +| [monitor_space](actions/monitor_space#readme) | Monitor and track minimum free disk space | composite | bash | +| [pinact](actions/pinact#readme) | Run pinact against repositories in an organization and create PRs for updates | composite | javascript | +| [more_space](actions/more_space#readme) | Free up disk space in GitHub Actions runners | composite | bash | +| [release_changelog](actions/release_changelog#readme) | Generate a changelog for the latest release | composite | javascript | +| [release_create](actions/release_create#readme) | Create a new release | composite | bash, javascript | +| [release_homebrew](actions/release_homebrew#readme) | Validate and update Homebrew formula | composite | bash, python | +| [release_setup](actions/release_setup#readme) | Prepare a release | docker | python | +| [screenshot](actions/screenshot#readme) | Setup cross-platform screenshot CLI tool | composite | bash | +| [setup_cuda](actions/setup_cuda#readme) | Set up NVIDIA CUDA Toolkit on Linux runners | composite | bash | +| [setup_python](actions/setup_python#readme) | Set up Python environment | composite | bash | +| [setup_virtual_desktop](actions/setup_virtual_desktop#readme) | Setup virtual desktop for GUI apps on Linux | composite | bash | ## Contributions diff --git a/actions/pinact/README.md b/actions/pinact/README.md new file mode 100644 index 0000000..4bf9d4a --- /dev/null +++ b/actions/pinact/README.md @@ -0,0 +1,213 @@ +# pinact + +A reusable action to run [pinact](https://github.com/suzuki-shunsuke/pinact) against repositories in an organization and create PRs for updates. + +Pinact is a tool that updates GitHub Actions to use commit hashes instead of tags, improving security by preventing tag hijacking attacks. + +## đŸ› ī¸ Prep Work + +### Prerequisites + +- GitHub token with permissions to: + - Read repositories + - Create branches + - Create pull requests + +## 🚀 Basic Usage + +See [action.yml](action.yml) + +```yaml +steps: + - name: Run Pinact + uses: LizardByte/actions/actions/pinact@master + with: + token: ${{ secrets.GITHUB_TOKEN }} +``` + +## đŸ“Ĩ Inputs + +| Name | Description | Default | Required | +|----------------|---------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------|----------| +| dryRun | Dry run mode. If true, will not push changes or create pull requests. | `false` | `false` | +| gitAuthorEmail | Git commit author email. | `41898282+github-actions[bot]@users.noreply.github.com` | `false` | +| gitAuthorName | Git commit author name. | `github-actions[bot]` | `false` | +| githubOrg | GitHub organization or user to process repositories from. Defaults to current repo owner. Ignored if repo is specified. | Current repository owner | `false` | +| includeForks | Include forked repositories when processing an organization. Has no effect when repo is specified. | `false` | `false` | +| pinactConfig | Pinact configuration file content (YAML). | Empty (no config file) | `false` | +| pinactRepo | Repository to use for pinact. Allows using a fork. Format: owner/repo. | `suzuki-shunsuke/pinact` | `false` | +| pinactVersion | Version of pinact to use. | `latest` | `false` | +| prBranchName | Name of the branch to create for the pull request. | `pinact-updates` | `false` | +| repo | Specific repository to run pinact on (format: owner/repo). If specified, runs only on this repo instead of all org repos. | Empty (runs on all org repos) | `false` | +| token | GitHub Token with permissions to read repositories and create pull requests. | N/A | `true` | + +## 📤 Outputs + +This action does not produce any outputs. It creates pull requests directly in the target repositories. + +## 🔒 Security Benefits + +Using pinact to update GitHub Actions provides several security benefits: + +- **Prevents tag hijacking**: Tags can be moved to point to different commits, but commit hashes are immutable +- **Improves supply chain security**: Ensures the exact version of an action is used +- **Audit trail**: Makes it clear exactly which version of an action is being used + +## đŸ–Ĩ Example Workflows + +### Dry run mode (preview changes without creating PRs) + +```yaml +name: Preview Pinact Changes + +on: + workflow_dispatch: + +jobs: + pinact-dry-run: + runs-on: ubuntu-latest + steps: + - name: Run Pinact in dry run mode + uses: LizardByte/actions/actions/pinact@master + with: + dryRun: true + token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Run on all repositories in an organization + +```yaml +name: Update Actions with Pinact + +on: + workflow_dispatch: + +jobs: + pinact: + runs-on: ubuntu-latest + steps: + - name: Run Pinact on all repos + uses: LizardByte/actions/actions/pinact@master + with: + token: ${{ secrets.ORG_ADMIN_TOKEN }} +``` + +### Run on a specific repository + +```yaml +name: Update Actions with Pinact + +on: + workflow_dispatch: + inputs: + repo: + description: 'Repository to update (format: owner/repo)' + required: true + +jobs: + pinact: + runs-on: ubuntu-latest + steps: + - name: Run Pinact on specific repo + uses: LizardByte/actions/actions/pinact@master + with: + repo: ${{ github.event.inputs.repo }} + token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Using a custom pinact fork + +```yaml +name: Update Actions with Pinact + +on: + workflow_dispatch: + +jobs: + pinact: + runs-on: ubuntu-latest + steps: + - name: Run Pinact with custom fork + uses: LizardByte/actions/actions/pinact@master + with: + pinactRepo: myorg/pinact + pinactVersion: v1.2.3 + token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Custom branch name and git author + +```yaml +name: Update Actions with Pinact + +on: + workflow_dispatch: + +jobs: + pinact: + runs-on: ubuntu-latest + steps: + - name: Run Pinact with custom settings + uses: LizardByte/actions/actions/pinact@master + with: + prBranchName: actions-security-update + gitAuthorName: Security Bot + gitAuthorEmail: security-bot@example.com + token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Using custom pinact configuration + +```yaml +name: Update Actions with Pinact + +on: + workflow_dispatch: + +jobs: + pinact: + runs-on: ubuntu-latest + steps: + - name: Run Pinact with custom config + uses: LizardByte/actions/actions/pinact@master + with: + pinactConfig: | + --- + # config content here + token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Include forked repositories + +```yaml +name: Update Actions with Pinact (Including Forks) + +on: + workflow_dispatch: + +jobs: + pinact: + runs-on: ubuntu-latest + steps: + - name: Run Pinact including forks + uses: LizardByte/actions/actions/pinact@master + with: + includeForks: true + token: ${{ secrets.ORG_ADMIN_TOKEN }} +``` + +## 📝 Notes + +- **Dry run mode**: Use `dryRun: true` to preview changes without pushing branches or creating PRs +- The action automatically filters out archived repositories when running on an organization +- By default, forked repositories are excluded. Use `includeForks: true` to include them +- If a pull request already exists for the specified branch, it will be updated instead of creating a duplicate +- The action detects the default branch automatically and will error if it cannot be determined +- API rate limiting is handled with automatic retry and exponential backoff +- Temporary clones are created and cleaned up automatically +- The diff output is displayed for each repository where changes are made + +## 🔗 See Also + +- [pinact](https://github.com/suzuki-shunsuke/pinact) - The upstream pinact tool +- [audit_repos](../audit_repos) - Audit repositories in an organization diff --git a/actions/pinact/action.yml b/actions/pinact/action.yml new file mode 100644 index 0000000..b551ba4 --- /dev/null +++ b/actions/pinact/action.yml @@ -0,0 +1,86 @@ +--- +name: "Pinact" +description: "Run pinact against repositories in an organization and create PRs for updates." +author: "LizardByte" + +branding: + icon: arrow-up-circle + color: green + +inputs: + dryRun: + description: 'Dry run mode. If true, will not push changes or create pull requests.' + required: false + default: 'false' + gitAuthorEmail: + description: 'Git commit author email.' + required: false + default: '41898282+github-actions[bot]@users.noreply.github.com' + gitAuthorName: + description: 'Git commit author name.' + required: false + default: 'github-actions[bot]' + githubOrg: + description: | + GitHub organization or user to process repositories from. + Defaults to the current repository owner. + Ignored if repo is specified. + required: false + default: '' + includeForks: + description: 'Include forked repositories when processing an organization. Has no effect when repo is specified.' + required: false + default: 'false' + pinactConfig: + description: 'Pinact configuration file content (YAML).' + required: false + default: '' + pinactRepo: + description: 'Repository to use for pinact. Allows using a fork. Format: owner/repo.' + required: false + default: 'suzuki-shunsuke/pinact' + pinactVersion: + description: 'Version of pinact to use.' + required: false + default: 'latest' + prBranchName: + description: 'Name of the branch to create for the pull request.' + required: false + default: 'pinact-updates' + repo: + description: | + Specific repository to run pinact on (format: owner/repo). + If specified, runs only on this repo instead of all org repos. + required: false + default: '' + token: + description: 'GitHub Token with permissions to read repositories and create pull requests.' + required: true + +runs: + using: "composite" + steps: + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: 'stable' + + - name: Run pinact on repositories + env: + INPUT_DRY_RUN: ${{ inputs.dryRun }} + INPUT_GIT_AUTHOR_EMAIL: ${{ inputs.gitAuthorEmail }} + INPUT_GIT_AUTHOR_NAME: ${{ inputs.gitAuthorName }} + INPUT_GITHUB_ORG: ${{ inputs.githubOrg }} + INPUT_INCLUDE_FORKS: ${{ inputs.includeForks }} + INPUT_PINACT_CONFIG: ${{ inputs.pinactConfig }} + INPUT_PINACT_REPO: ${{ inputs.pinactRepo }} + INPUT_PINACT_VERSION: ${{ inputs.pinactVersion }} + INPUT_PR_BRANCH_NAME: ${{ inputs.prBranchName }} + INPUT_REPO: ${{ inputs.repo }} + PINACT_GITHUB_TOKEN: ${{ inputs.token }} + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ inputs.token }} + script: | + const script = require('${{ github.action_path }}/pinact.js'); + await script({ github, context, core }); diff --git a/actions/pinact/ci-matrix.json b/actions/pinact/ci-matrix.json new file mode 100644 index 0000000..d7162d9 --- /dev/null +++ b/actions/pinact/ci-matrix.json @@ -0,0 +1,10 @@ +[ + { + "runs-on": "ubuntu-latest", + "with": { + "dryRun": "true", + "repo": "LizardByte/actions", + "token": "${ secrets.GITHUB_TOKEN }" + } + } +] diff --git a/actions/pinact/pinact.js b/actions/pinact/pinact.js new file mode 100644 index 0000000..362c221 --- /dev/null +++ b/actions/pinact/pinact.js @@ -0,0 +1,798 @@ +/** + * Pinact automation script for GitHub Actions + * This script runs pinact on repositories to update GitHub Actions to use commit hashes. + */ + +const { execSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +// ANSI color codes for console output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + // Foreground colors + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + // Background colors + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', +}; + +/** + * Logger class for colored console output + */ +class Logger { + /** + * Colorize text with the given color codes + * @param {string} text - Text to colorize + * @param {string} colorCode - ANSI color code + * @returns {string} Colorized text + */ + static colorize(text, colorCode) { + return colorCode + text + colors.reset; + } + + /** + * Log a header message + * @param {string} message - Message to log + */ + static header(message) { + const coloredMsg = this.colorize(message, colors.bright + colors.cyan); + console.log(coloredMsg); + } + + /** + * Log a success message + * @param {string} message - Message to log + */ + static success(message) { + const coloredMsg = this.colorize(message, colors.green); + console.log(coloredMsg); + } + + /** + * Log an error message + * @param {string} message - Message to log + */ + static error(message) { + const coloredMsg = this.colorize(message, colors.red); + console.log(coloredMsg); + } + + /** + * Log a warning message + * @param {string} message - Message to log + */ + static warning(message) { + const coloredMsg = this.colorize(message, colors.yellow); + console.log(coloredMsg); + } + + /** + * Log an info message + * @param {string} message - Message to log + */ + static info(message) { + const coloredMsg = this.colorize(message, colors.blue); + console.log(coloredMsg); + } + + /** + * Log a plain message + * @param {string} message - Message to log + */ + static log(message) { + console.log(message); + } +} + +/** + * Execute a shell command and return the output + * @param {string} command - Command to execute + * @param {Object} options - Execution options + * @returns {string} Command output + */ +function execCommand(command, options = {}) { + try { + return execSync(command, { encoding: 'utf-8', ...options }); + } catch (error) { + throw new Error(`Command failed: ${command}\n${error.message}`); + } +} + +/** + * Install pinact + * @param {string} pinactRepo - Repository to install from (format: owner/repo) + * @param {string} pinactVersion - Version to install (tag, commit hash, or 'latest') + * @returns {string} Path to pinact binary + */ +function installPinact(pinactRepo, pinactVersion) { + Logger.log(''); + Logger.header('=== Installing pinact ==='); + Logger.log('Repository: ' + pinactRepo); + Logger.log('Version: ' + pinactVersion); + Logger.log(''); + + try { + const gopath = execCommand('go env GOPATH').trim(); + const pinactPath = path.join(gopath, 'bin', process.platform === 'win32' ? 'pinact.exe' : 'pinact'); + + // Check if this is using a custom repo or non-standard version + const isDefaultRepo = pinactRepo === 'suzuki-shunsuke/pinact'; + const isStandardVersion = pinactVersion === 'latest' || /^v?\d+\.\d+\.\d+/.test(pinactVersion); + + // Only use go install for default repo with standard versions + if (isDefaultRepo && isStandardVersion) { + // Use go install for standard versions from the default repo + const installUrl = pinactVersion === 'latest' + ? 'github.com/' + pinactRepo + '/cmd/pinact@latest' + : 'github.com/' + pinactRepo + '/cmd/pinact@' + pinactVersion; + + execCommand('go install ' + installUrl, { stdio: 'inherit' }); + } else { + // For custom repos, branches, or commit hashes, clone and build manually + Logger.info('Building from source (repo: ' + pinactRepo + ', version: ' + pinactVersion + ')...'); + Logger.log(''); + + const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'pinact-build-')); + const repoPath = path.join(tmpDir, 'pinact'); + + try { + // Clone the repository + execCommand('git clone https://github.com/' + pinactRepo + '.git ' + repoPath, { stdio: 'inherit' }); + + // Checkout the specific version (branch or commit) + execCommand('git checkout ' + pinactVersion, { cwd: repoPath, stdio: 'inherit' }); + + // Build and install + execCommand('go install ./cmd/pinact', { cwd: repoPath, stdio: 'inherit' }); + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (buildError) { + // Clean up on error + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + throw buildError; + } + } + + Logger.success('✅ Pinact installed at: ' + pinactPath); + Logger.log(''); + return pinactPath; + } catch (error) { + throw new Error('Failed to install pinact: ' + error.message); + } +} + +/** + * Run pinact on a repository + * @param {string} pinactPath - Path to pinact binary + * @param {string} repoPath - Path to the cloned repository + * @param {string} pinactConfigPath - Optional path to pinact configuration file + * @returns {boolean} True if changes were made + */ +function runPinact(pinactPath, repoPath, pinactConfigPath = '') { + Logger.log('Running pinact on repository...'); + + + try { + // Run pinact with the 'run' command, optionally with --config + let pinactCommand = '"' + pinactPath + '" run'; + if (pinactConfigPath) { + pinactCommand += ' --config "' + pinactConfigPath + '"'; + } + execCommand(pinactCommand, { cwd: repoPath, stdio: 'inherit' }); + + // Check if there are any changes + const status = execCommand('git status --porcelain', { cwd: repoPath }).trim(); + + if (status) { + Logger.success('✅ Pinact made changes to workflow files'); + Logger.log(''); + + // Show the diff with color + Logger.header('=== Changes made by pinact ==='); + Logger.log(''); + try { + const diff = execCommand('git diff --color=always .github/workflows', { cwd: repoPath }); + Logger.log(diff); + } catch (diffError) { + Logger.warning('âš ī¸ Could not display diff: ' + diffError.message); + } + Logger.header('=== End of diff ==='); + Logger.log(''); + + return true; + } else { + Logger.info('â„šī¸ No changes made by pinact'); + Logger.log(''); + return false; + } + } catch (error) { + throw new Error('Failed to run pinact: ' + error.message); + } +} + +/** + * Configure git for commits + * @param {string} repoPath - Path to the repository + * @param {string} authorName - Git author name + * @param {string} authorEmail - Git author email + */ +function configureGit(repoPath, authorName, authorEmail) { + execCommand(`git config user.name "${authorName}"`, { cwd: repoPath }); + execCommand(`git config user.email "${authorEmail}"`, { cwd: repoPath }); +} + +/** + * Create a branch, commit changes, and push + * @param {string} repoPath - Path to the repository + * @param {string} branchName - Name of the branch to create + * @param {string} authorName - Git author name + * @param {string} authorEmail - Git author email + * @param {boolean} dryRun - Whether this is a dry run + */ +function commitAndPush(repoPath, branchName, authorName, authorEmail, dryRun) { + Logger.log('Creating branch and committing changes...'); + + configureGit(repoPath, authorName, authorEmail); + + // Create and checkout new branch + execCommand('git checkout -b ' + branchName, { cwd: repoPath }); + + // Add all changes + execCommand('git add .github/workflows', { cwd: repoPath }); + + // Commit changes + const commitMessage = 'chore: update GitHub Actions to use commit hashes'; + execCommand('git commit -m "' + commitMessage + '"', { cwd: repoPath }); + + if (dryRun) { + Logger.info('🔍 DRY RUN: Would push to branch: ' + branchName + ' (skipping)'); + Logger.log(''); + } else { + // Push branch + execCommand('git push origin ' + branchName, { cwd: repoPath }); + Logger.success('✅ Changes committed and pushed to branch: ' + branchName); + Logger.log(''); + } +} + +/** + * Check if a pull request already exists + * @param {Object} github - GitHub API object + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} branchName - Branch name to check + * @returns {Promise} Existing PR or null + */ +async function findExistingPR(github, owner, repo, branchName) { + try { + const { data: pulls } = await retryWithBackoff(async () => { + return await github.rest.pulls.list({ + owner: owner, + repo: repo, + state: 'open', + head: owner + ':' + branchName + }); + }); + + return pulls.length > 0 ? pulls[0] : null; + } catch (error) { + Logger.warning('âš ī¸ Could not check for existing PRs: ' + error.message); + return null; + } +} + +/** + * Create a pull request + * @param {Object} github - GitHub API object + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} branchName - Branch name + * @param {string} defaultBranch - Default branch name + * @param {boolean} dryRun - Whether this is a dry run + * @returns {Promise} Created pull request + */ +async function createPullRequest(github, owner, repo, branchName, defaultBranch, dryRun) { + Logger.log('Creating pull request...'); + + if (dryRun) { + Logger.info('🔍 DRY RUN: Would create PR from ' + branchName + ' to ' + defaultBranch + ' (skipping)'); + Logger.log(''); + return { html_url: 'dry-run-mode', number: 0 }; + } + + // Check if PR already exists + const existingPR = await findExistingPR(github, owner, repo, branchName); + + if (existingPR) { + Logger.info('â„šī¸ Pull request already exists: ' + existingPR.html_url); + Logger.log(''); + return existingPR; + } + + const title = 'chore: update GitHub Actions to use commit hashes'; + const body = `This PR updates GitHub Actions to use commit hashes instead of tags for improved security. + +Changes were automatically generated by [pinact](https://github.com/suzuki-shunsuke/pinact). + +## Benefits +- Prevents tag hijacking attacks +- Ensures immutable action versions +- Improves security posture + +Please review the changes before merging.`; + + try { + const { data: pr } = await github.rest.pulls.create({ + owner: owner, + repo: repo, + title: title, + body: body, + head: branchName, + base: defaultBranch + }); + + Logger.success('✅ Pull request created: ' + pr.html_url); + Logger.log(''); + return pr; + } catch (error) { + throw new Error('Failed to create pull request: ' + error.message); + } +} + +/** + * Sleep for a specified number of milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Retry an async function with exponential backoff + * @param {Function} fn - Async function to retry + * @param {number} maxRetries - Maximum number of retries + * @param {number} baseDelay - Base delay in milliseconds + * @returns {Promise<*>} Result of the function + */ +async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) { + let lastError; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + // Check if it's a rate limit error + const isRateLimit = error.status === 429 || + error.status === 403 && error.message.includes('rate limit'); + + if (isRateLimit && attempt < maxRetries) { + // Get retry-after header if available + const retryAfter = error.response?.headers['retry-after']; + const delay = retryAfter ? Number.parseInt(retryAfter) * 1000 : baseDelay * Math.pow(2, attempt); + + const seconds = delay / 1000; + const attemptInfo = '(attempt ' + (attempt + 1) + '/' + maxRetries + ')'; + Logger.warning('âš ī¸ Rate limit hit, retrying in ' + seconds + ' seconds... ' + attemptInfo); + await sleep(delay); + continue; + } + + // For non-rate-limit errors, don't retry + if (!isRateLimit) { + throw error; + } + } + } + + throw lastError; +} + +/** + * Get the default branch for a repository + * @param {Object} github - GitHub API object + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {Promise} Default branch name + */ +async function getDefaultBranch(github, owner, repo) { + try { + const { data: repository } = await retryWithBackoff(async () => { + return await github.rest.repos.get({ + owner: owner, + repo: repo + }); + }); + + if (!repository.default_branch) { + throw new Error('Could not determine default branch for ' + owner + '/' + repo); + } + + return repository.default_branch; + } catch (error) { + throw new Error('Failed to fetch default branch for ' + owner + '/' + repo + ': ' + error.message); + } +} + +/** + * Clone a repository + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} token - GitHub token + * @param {string} targetPath - Path to clone to + * @param {string} defaultBranch - Default branch to checkout + */ +function cloneRepository(owner, repo, token, targetPath, defaultBranch) { + Logger.log('Cloning repository ' + owner + '/' + repo + '...'); + + const repoUrl = 'https://x-access-token:' + token + '@github.com/' + owner + '/' + repo + '.git'; + execCommand('git clone --depth 1 --branch ' + defaultBranch + ' ' + repoUrl + ' ' + targetPath, { stdio: 'inherit' }); + + Logger.success('✅ Repository cloned'); + Logger.log(''); +} + +/** + * Process a single repository + * @param {Object} github - GitHub API object + * @param {Object} options - Processing options + * @param {string} options.owner - Repository owner + * @param {string} options.repo - Repository name + * @param {string} options.token - GitHub token + * @param {string} options.pinactPath - Path to pinact binary + * @param {string} options.pinactConfigPath - Optional path to pinact configuration file + * @param {string} options.branchName - Branch name for PR + * @param {string} options.authorName - Git author name + * @param {string} options.authorEmail - Git author email + * @param {boolean} options.dryRun - Whether this is a dry run + * @returns {Promise} Result object with success, prCreated, and error properties + */ +async function processRepository(github, options) { + const { owner, repo, token, pinactPath, pinactConfigPath, branchName, authorName, authorEmail, dryRun } = options; + Logger.log(''); + Logger.header('=== Processing ' + owner + '/' + repo + ' ==='); + Logger.log(''); + + // Get default branch + const defaultBranch = await getDefaultBranch(github, owner, repo); + Logger.log('Default branch: ' + defaultBranch); + Logger.log(''); + + // Create temporary directory for cloning + const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'pinact-')); + const repoPath = path.join(tmpDir, repo); + + try { + // Clone repository + cloneRepository(owner, repo, token, repoPath, defaultBranch); + + // Run pinact + const hasChanges = runPinact(pinactPath, repoPath, pinactConfigPath); + + if (hasChanges) { + // Commit and push changes + commitAndPush(repoPath, branchName, authorName, authorEmail, dryRun); + + // Create pull request + await createPullRequest(github, owner, repo, branchName, defaultBranch, dryRun); + + return { success: true, prCreated: true }; + } else { + Logger.log('No changes needed for ' + owner + '/' + repo); + Logger.log(''); + return { success: true, prCreated: false }; + } + } catch (error) { + Logger.error('❌ Failed to process ' + owner + '/' + repo + ': ' + error.message); + Logger.log(''); + return { success: false, prCreated: false, error: error.message }; + } finally { + // Clean up temporary directory + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (error) { + Logger.warning('âš ī¸ Failed to clean up temporary directory: ' + error.message); + } + } +} + +/** + * Fetch all repositories from the organization + * @param {Object} github - GitHub API object + * @param {string} owner - Organization or username + * @param {boolean} includeForks - Whether to include forked repositories + * @returns {Promise} Array of repository objects + */ +async function fetchRepositories(github, owner, includeForks = false) { + Logger.log(''); + Logger.header('=== Fetching repositories from ' + owner + ' ==='); + Logger.log(''); + + // Try to fetch as org first, fall back to user repos + let allRepos; + try { + const opts = github.rest.repos.listForOrg.endpoint.merge({ + org: owner, + per_page: 100 + }); + allRepos = await retryWithBackoff(async () => { + return await github.paginate(opts); + }); + } catch (error) { + if (error.status === 404) { + // Not an org, try as user + Logger.info('Not an organization, trying as user...'); + Logger.log(''); + const opts = github.rest.repos.listForUser.endpoint.merge({ + username: owner, + per_page: 100 + }); + allRepos = await retryWithBackoff(async () => { + return await github.paginate(opts); + }); + } else { + throw error; + } + } + + // Filter out archived repos, and optionally forked repos + const repos = includeForks + ? allRepos.filter(repo => !repo.archived) + : allRepos.filter(repo => !repo.archived && !repo.fork); + + const repoCount = repos.length; + const totalCount = allRepos.length; + const excludedTypes = includeForks ? 'archived' : 'archived and forked'; + Logger.log('Found ' + repoCount + ' repositories to process (' + totalCount + ' total, excluding ' + excludedTypes + ')'); + Logger.log(''); + + return repos.map(repo => repo.name); +} + +/** + * Setup pinact configuration + * @param {string} pinactConfig - Pinact configuration YAML content + * @returns {string} Path to config file if created, empty string otherwise + */ +function setupPinactConfig(pinactConfig) { + if (!pinactConfig) { + return ''; + } + + const configDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'pinact-config-')); + const pinactConfigPath = path.join(configDir, '.pinact.yaml'); + fs.writeFileSync(pinactConfigPath, pinactConfig, 'utf-8'); + Logger.success('Created pinact configuration file at ' + pinactConfigPath); + Logger.log(''); + return pinactConfigPath; +} + +/** + * Determine which repositories to process + * @param {string} repo - Specific repository (owner/repo format) + * @param {Object} github - GitHub API object + * @param {string} githubOrg - Organization or user + * @param {boolean} includeForks - Whether to include forked repositories + * @param {Object} core - GitHub Actions core object + * @returns {Promise} Object with owner and repos array + */ +async function determineRepositories(repo, github, githubOrg, includeForks, core) { + if (repo) { + // Process single repository + const parts = repo.split('/'); + if (parts.length !== 2) { + core.setFailed('Invalid repo format: ' + repo + '. Expected format: owner/repo'); + return null; + } + return { owner: parts[0], repos: [parts[1]] }; + } else { + // Process all repositories in org + const repos = await fetchRepositories(github, githubOrg, includeForks); + return { owner: githubOrg, repos: repos }; + } +} + +/** + * Process all repositories + * @param {Object} github - GitHub API object + * @param {Array} repos - Array of repository names + * @param {string} owner - Repository owner + * @param {Object} options - Processing options + * @returns {Promise} Results and errors + */ +async function processAllRepositories(github, repos, owner, options) { + const results = []; + const errors = []; + + for (const repoName of repos) { + const result = await processRepository(github, { + owner, + repo: repoName, + ...options + }); + + if (result.prCreated) { + results.push(repoName); + } + + if (!result.success) { + errors.push({ repo: repoName, error: result.error }); + } + } + + return { results, errors }; +} + +/** + * Print action configuration + * @param {Object} config - Configuration object + */ +function printActionConfig(config) { + Logger.header('=== Pinact Action ==='); + Logger.log(''); + const dryRunStatus = config.dryRun ? 'ENABLED' : 'DISABLED'; + Logger.log('Dry Run Mode: ' + (config.dryRun ? Logger.colorize(dryRunStatus, colors.blue) : dryRunStatus)); + Logger.log('Git Author: ' + config.gitAuthorName + ' <' + config.gitAuthorEmail + '>'); + Logger.log('PR Branch Name: ' + config.prBranchName); + Logger.log('Pinact Repository: ' + config.pinactRepo); + Logger.log('Pinact Version: ' + config.pinactVersion); + if (config.pinactConfig) { + const lineCount = config.pinactConfig.split('\n').length; + Logger.log('Pinact Config: Provided (' + lineCount + ' lines)'); + } + + if (config.repo) { + Logger.log('Target: Specific repository (' + config.repo + ')'); + } else { + Logger.log('Target: All repositories in ' + config.githubOrg); + } +} + +/** + * Print summary + * @param {number} totalRepos - Total repositories processed + * @param {Array} results - Results array + * @param {Array} errors - Errors array + * @param {string} owner - Repository owner + * @param {boolean} dryRun - Whether this is a dry run + */ +function printSummary(totalRepos, results, errors, owner, dryRun) { + Logger.log(''); + Logger.header('=== Summary ==='); + Logger.log(''); + Logger.log('Repositories processed: ' + totalRepos); + if (dryRun) { + Logger.log('Pull requests that would be created: ' + results.length); + } else { + Logger.log('Pull requests created/updated: ' + results.length); + } + + if (results.length > 0) { + const prType = dryRun ? 'potential ' : ''; + Logger.log(''); + Logger.log('Repositories with ' + prType + 'PRs:'); + for (const repoName of results) { + Logger.log(' - ' + owner + '/' + repoName); + } + } + + if (errors.length > 0) { + Logger.log(''); + Logger.error('❌ Errors occurred in ' + errors.length + ' repository(ies):'); + for (const { repo, error } of errors) { + Logger.log(' - ' + owner + '/' + repo + ': ' + error); + } + } +} + +/** + * Cleanup pinact configuration + * @param {string} pinactConfigPath - Path to config file + */ +function cleanupPinactConfig(pinactConfigPath) { + if (!pinactConfigPath) { + return; + } + + try { + const configDir = path.dirname(pinactConfigPath); + fs.rmSync(configDir, { recursive: true, force: true }); + } catch (error) { + Logger.warning('âš ī¸ Failed to clean up config directory: ' + error.message); + } +} + +/** + * Main function + */ +async function runPinactAction({ github, context, core }) { + // Parse inputs + const dryRun = process.env.INPUT_DRY_RUN === 'true'; + const gitAuthorEmail = process.env.INPUT_GIT_AUTHOR_EMAIL; + const gitAuthorName = process.env.INPUT_GIT_AUTHOR_NAME; + const githubOrg = process.env.INPUT_GITHUB_ORG || context.repo.owner; + const includeForks = process.env.INPUT_INCLUDE_FORKS === 'true'; + const pinactConfig = process.env.INPUT_PINACT_CONFIG || ''; + const pinactRepo = process.env.INPUT_PINACT_REPO; + const pinactVersion = process.env.INPUT_PINACT_VERSION; + const prBranchName = process.env.INPUT_PR_BRANCH_NAME; + const repo = process.env.INPUT_REPO; + const token = process.env.GITHUB_TOKEN || process.env.INPUT_TOKEN; + + printActionConfig({ + dryRun, + gitAuthorEmail, + gitAuthorName, + githubOrg, + pinactConfig, + pinactRepo, + pinactVersion, + prBranchName, + repo + }); + + try { + let pinactConfigPath = ''; + + // Install pinact + const pinactPath = installPinact(pinactRepo, pinactVersion); + + // Create config file if config is provided + pinactConfigPath = setupPinactConfig(pinactConfig); + + // Determine which repositories to process + const repoInfo = await determineRepositories(repo, github, githubOrg, includeForks, core); + if (!repoInfo) { + return; // Error already set by determineRepositories + } + + const { owner, repos: reposToProcess } = repoInfo; + + // Process each repository + const { results, errors } = await processAllRepositories(github, reposToProcess, owner, { + token, + pinactPath, + pinactConfigPath, + branchName: prBranchName, + authorName: gitAuthorName, + authorEmail: gitAuthorEmail, + dryRun + }); + + // Print summary + printSummary(reposToProcess.length, results, errors, owner, dryRun); + + if (errors.length > 0) { + core.setFailed('Failed to process ' + errors.length + ' repository(ies). See logs for details.'); + return; + } + + Logger.log(''); + Logger.success('✅ Pinact action completed successfully!'); + + // Clean up config file if it was created + cleanupPinactConfig(pinactConfigPath); + } catch (error) { + core.setFailed('Pinact action failed: ' + error.message); + } +} + +module.exports = runPinactAction; diff --git a/tests/pinact/test_pinact.test.js b/tests/pinact/test_pinact.test.js new file mode 100644 index 0000000..8250104 --- /dev/null +++ b/tests/pinact/test_pinact.test.js @@ -0,0 +1,1184 @@ +/** + * Unit tests for pinact.js + * @jest-environment node + */ + +/* eslint-env jest */ + +const { execSync } = require('node:child_process'); +const fs = require('node:fs'); + +// Mock child_process +jest.mock('node:child_process'); + +// Mock fs +jest.mock('node:fs'); + +// Mock the GitHub Actions core, context, and GitHub objects +const mockCore = { + setFailed: jest.fn(), +}; + +const mockContext = { + repo: { + owner: 'test-org', + }, +}; + +const mockGithub = { + rest: { + repos: { + listForOrg: { + endpoint: { + merge: jest.fn(), + }, + }, + listForUser: { + endpoint: { + merge: jest.fn(), + }, + }, + get: jest.fn(), + }, + pulls: { + list: jest.fn(), + create: jest.fn(), + }, + }, + paginate: jest.fn(), +}; + +// Mock console methods +const originalConsoleLog = console.log; +let consoleOutput = []; + +// Helper function to create a standard repository data object +function createRepoData(overrides = {}) { + return { + name: 'repo1', + description: 'Test repository.', + html_url: 'https://github.com/test-org/repo1', + default_branch: 'master', + archived: false, + fork: false, + ...overrides, + }; +} + +// Helper function to create a standard repository list item +function createRepoListItem(overrides = {}) { + return { + name: 'repo1', + archived: false, + fork: false, + private: false, + ...overrides, + }; +} + +beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + consoleOutput = []; + console.log = jest.fn((...args) => { + consoleOutput.push(args.join(' ')); + }); + + // Set default environment variables + process.env.INPUT_DRY_RUN = 'false'; + process.env.INPUT_GIT_AUTHOR_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'; + process.env.INPUT_GIT_AUTHOR_NAME = 'github-actions[bot]'; + process.env.INPUT_GITHUB_ORG = 'test-org'; + process.env.INPUT_PINACT_REPO = 'suzuki-shunsuke/pinact'; + process.env.INPUT_PINACT_VERSION = 'latest'; + process.env.INPUT_PR_BRANCH_NAME = 'pinact-updates'; + process.env.INPUT_REPO = ''; + process.env.GITHUB_TOKEN = 'test-token'; + + // Setup default fs mocks + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['workflow.yml']); + fs.mkdtempSync.mockImplementation((prefix) => { + if (prefix.includes('pinact-config-')) { + return '.tmp/pinact-config-test'; + } + return '.tmp/pinact-test'; + }); + fs.rmSync.mockImplementation(() => {}); + fs.writeFileSync.mockImplementation(() => {}); + + // Setup default execSync mocks + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git status --porcelain')) { + return ''; // No changes by default + } + if (command.includes('git diff')) { + return ''; // No diff by default + } + return ''; + }); +}); + +afterEach(() => { + console.log = originalConsoleLog; +}); + +// Import the module after setting up mocks +const runPinactAction = require('../../actions/pinact/pinact.js'); + +// Helper functions to reduce code duplication + +/** + * Setup basic mocks for a successful action run with no repos + */ +function setupBasicMocks() { + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([]); +} + +/** + * Setup execSync mock for repository with changes + */ +function setupExecSyncWithChanges() { + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git status --porcelain')) { + return 'M .github/workflows/test.yml\n'; + } + if (command.includes('git diff')) { + return 'diff --git a/.github/workflows/test.yml'; + } + return ''; + }); +} + +/** + * Setup execSync mock for default branch detection with changes + */ +function setupExecSyncForDefaultBranch() { + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git status --porcelain')) { + return 'M .github/workflows/test.yml\n'; + } + return ''; + }); +} + +/** + * Setup mocks for a single repository with changes + */ +function setupSingleRepoWithChanges() { + setupExecSyncWithChanges(); + + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([createRepoListItem()]); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + mockGithub.rest.pulls.create.mockResolvedValue({ + data: { html_url: 'https://github.com/test-org/repo1/pull/1', number: 1 } + }); +} + +/** + * Setup mocks for a single repository without changes + */ +function setupSingleRepoNoChanges() { + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([createRepoListItem()]); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); +} + +/** + * Setup mocks for multiple repositories with changes + */ +function setupMultipleReposWithChanges() { + setupExecSyncWithChanges(); + + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([ + createRepoListItem({ name: 'repo1' }), + createRepoListItem({ name: 'repo2' }), + ]); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + mockGithub.rest.pulls.create.mockResolvedValue({ + data: { html_url: 'https://github.com/test-org/repo1/pull/1' } + }); +} + +/** + * Setup mocks for error scenarios + */ +function setupErrorMocks(errorType) { + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (errorType === 'clone' && command.includes('git clone')) { + throw new Error('Clone failed'); + } + if (errorType === 'pinact' && command.includes('pinact') && command.includes('run') && !command.includes('go install')) { + const error = new Error('Command failed: pinact run\nPinact execution failed'); + error.status = 1; + throw error; + } + if (command.includes('git status') || command.includes('git config')) { + return ''; + } + return ''; + }); +} + +/** + * Setup mocks for diff error + */ +function setupDiffErrorMocks() { + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git status --porcelain')) { + return 'M .github/workflows/test.yml\n'; + } + if (command.includes('git diff')) { + throw new Error('Diff failed'); + } + return ''; + }); +} + +/** + * Setup GitHub mocks for single repo with various configurations + */ +function setupGitHubMocksForSingleRepo(options = {}) { + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([createRepoListItem()]); + mockGithub.rest.repos.get.mockResolvedValue({ + data: createRepoData(options.repoDataOverrides || {}) + }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: options.existingPulls || [] }); + if (options.withPullCreate !== false) { + mockGithub.rest.pulls.create.mockResolvedValue({ + data: { html_url: 'https://github.com/test-org/repo1/pull/1' } + }); + } +} + +/** + * Run test with different platform + */ +async function runWithPlatform(platform, testFn) { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform }); + try { + await testFn(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } +} + +/** + * Verify execSync was called with git clone + */ +function expectGitClone(repoUrl) { + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git clone ' + repoUrl), + expect.any(Object) + ); +} + +/** + * Verify execSync was called with git checkout + */ +function expectGitCheckout(ref) { + expect(execSync).toHaveBeenCalledWith( + 'git checkout ' + ref, + expect.any(Object) + ); +} + +/** + * Verify execSync was called with go install ./cmd/pinact + */ +function expectGoInstallLocal() { + expect(execSync).toHaveBeenCalledWith( + 'go install ./cmd/pinact', + expect.any(Object) + ); +} + +/** + * Setup execSync mock for no changes + */ +function setupExecSyncNoChanges() { + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git status --porcelain')) { + return ''; // No changes + } + return ''; + }); +} + +/** + * Setup execSync mock with detailed diff output + */ +function setupExecSyncWithDetailedDiff() { + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git status --porcelain')) { + return 'M .github/workflows/test.yml\n'; + } + if (command.includes('git diff')) { + return 'diff --git a/.github/workflows/test.yml\n--- a/.github/workflows/test.yml\n+++ b/.github/workflows/test.yml'; + } + return ''; + }); +} + +/** + * Test default branch error with fake timers + */ +async function testDefaultBranchError(defaultBranchValue) { + jest.useFakeTimers(); + + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([createRepoListItem()]); + + const repoData = createRepoData(); + if (defaultBranchValue === 'DELETE') { + delete repoData.default_branch; + } else { + repoData.default_branch = defaultBranchValue; + } + + mockGithub.rest.repos.get.mockResolvedValue({ data: repoData }); + + const actionPromise = runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + await jest.runAllTimersAsync(); + await actionPromise; + + jest.useRealTimers(); + + expect(mockGithub.rest.repos.get).toHaveBeenCalled(); +} + +/** + * Setup and run test for repository filtering + */ +async function testRepositoryFiltering(repos, expectedCount, expectedMessage) { + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue(repos); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Found ' + expectedCount + ' repositories'))).toBe(true); + if (expectedMessage) { + expect(consoleOutput.some(line => line.includes(expectedMessage))).toBe(true); + } +} + +describe('Pinact Action', () => { + describe('Installation', () => { + test('should install pinact with latest version', async () => { + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest'), + expect.any(Object) + ); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + test('should install pinact with specific version', async () => { + process.env.INPUT_PINACT_VERSION = 'v1.2.3'; + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('go install github.com/suzuki-shunsuke/pinact/cmd/pinact@v1.2.3'), + expect.any(Object) + ); + }); + + test('should install pinact from custom repository', async () => { + process.env.INPUT_PINACT_REPO = 'custom-org/pinact-fork'; + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Custom repos should use clone and build, not go install + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git clone https://github.com/custom-org/pinact-fork.git'), + expect.any(Object) + ); + expect(execSync).toHaveBeenCalledWith( + 'go install ./cmd/pinact', + expect.any(Object) + ); + }); + + test('should install pinact from custom repository with specific version', async () => { + process.env.INPUT_PINACT_REPO = 'custom-org/pinact-fork'; + process.env.INPUT_PINACT_VERSION = 'v1.2.3'; + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Custom repos should use clone and build even with standard version + expectGitClone('https://github.com/custom-org/pinact-fork.git'); + expectGitCheckout('v1.2.3'); + expectGoInstallLocal(); + }); + + test('should build from source for branch names', async () => { + process.env.INPUT_PINACT_VERSION = 'feat/my-branch'; + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expectGitClone('https://github.com/suzuki-shunsuke/pinact.git'); + expectGitCheckout('feat/my-branch'); + expectGoInstallLocal(); + }); + + test('should build from source for commit hashes', async () => { + process.env.INPUT_PINACT_VERSION = 'abc123def456'; + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git clone'), + expect.any(Object) + ); + expectGitCheckout('abc123def456'); + }); + + test('should handle installation failure', async () => { + execSync.mockImplementation((command) => { + if (command.includes('go install')) { + throw new Error('Installation failed'); + } + return ''; + }); + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining('Installation failed') + ); + }); + + test('should handle build from source failure and cleanup errors', async () => { + process.env.INPUT_PINACT_VERSION = 'feat/my-branch'; + + execSync.mockImplementation((command) => { + if (command.includes('go env GOPATH')) { + return '/home/user/go\n'; + } + if (command.includes('git clone')) { + return ''; + } + if (command.includes('git checkout')) { + throw new Error('Checkout failed'); + } + return ''; + }); + + // Make cleanup also fail + let rmSyncCallCount = 0; + fs.rmSync.mockImplementation(() => { + rmSyncCallCount++; + if (rmSyncCallCount === 1) { + // First cleanup attempt (in catch block) should fail + throw new Error('Cleanup failed'); + } + // Other cleanup calls succeed + }); + + setupBasicMocks(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining('Checkout failed') + ); + // Verify cleanup was attempted despite failing + expect(fs.rmSync).toHaveBeenCalled(); + }); + + test('should detect Windows platform for pinact binary path', async () => { + setupBasicMocks(); + + await runWithPlatform('win32', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('go install'), + expect.any(Object) + ); + }); + + test('should detect non-Windows platform for pinact binary path', async () => { + setupBasicMocks(); + + await runWithPlatform('linux', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('go install'), + expect.any(Object) + ); + }); + + test('should use context.repo.owner when INPUT_GITHUB_ORG is not set', async () => { + delete process.env.INPUT_GITHUB_ORG; + + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Should use context.repo.owner (test-org) + expect(consoleOutput.some(line => line.includes('test-org'))).toBe(true); + + // Restore INPUT_GITHUB_ORG + process.env.INPUT_GITHUB_ORG = 'test-org'; + }); + + test('should use INPUT_TOKEN when GITHUB_TOKEN is not set', async () => { + delete process.env.GITHUB_TOKEN; + process.env.INPUT_TOKEN = 'input-token-value'; + + setupSingleRepoWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Verify token was used (check for git clone with token) + const cloneCalls = execSync.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('git clone') + ); + expect(cloneCalls.length).toBeGreaterThan(0); + expect(cloneCalls[0][0]).toContain('x-access-token:'); + + // Restore GITHUB_TOKEN + process.env.GITHUB_TOKEN = 'test-token'; + }); + }); + + describe('Repository Fetching', () => { + test('should fetch repositories from organization', async () => { + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([ + createRepoListItem({ name: 'repo1' }), + createRepoListItem({ name: 'repo2' }), + ]); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockGithub.paginate).toHaveBeenCalled(); + expect(consoleOutput.some(line => line.includes('Found 2 repositories'))).toBe(true); + }); + + test('should filter out archived repositories', async () => { + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([ + createRepoListItem({ name: 'repo1', archived: false }), + createRepoListItem({ name: 'repo2', archived: true }), + ]); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Found 1 repositories'))).toBe(true); + }); + + test('should filter out forked repositories by default', async () => { + await testRepositoryFiltering( + [ + createRepoListItem({ name: 'repo1', fork: false }), + createRepoListItem({ name: 'repo2', fork: true }), + ], + 1, + 'excluding archived and forked' + ); + }); + + test('should include forked repositories when includeForks is true', async () => { + process.env.INPUT_INCLUDE_FORKS = 'true'; + + await testRepositoryFiltering( + [ + createRepoListItem({ name: 'repo1', fork: false }), + createRepoListItem({ name: 'repo2', fork: true }), + ], + 2, + 'excluding archived' + ); + + expect(consoleOutput.some(line => line.includes('excluding archived and forked'))).toBe(false); + + // Clean up + delete process.env.INPUT_INCLUDE_FORKS; + }); + + test('should fallback to user repos if org fetch fails', async () => { + const notFoundError = new Error('Not Found'); + notFoundError.status = 404; + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate + .mockRejectedValueOnce(notFoundError) + .mockResolvedValueOnce([createRepoListItem()]); + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData() }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Not an organization'))).toBe(true); + }); + }); + + describe('Single Repository Mode', () => { + test('should process specific repository when repo is specified', async () => { + process.env.INPUT_REPO = 'test-org/specific-repo'; + mockGithub.rest.repos.get.mockResolvedValue({ data: createRepoData({ name: 'specific-repo' }) }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Specific repository'))).toBe(true); + expect(consoleOutput.some(line => line.includes('specific-repo'))).toBe(true); + }); + + test('should handle invalid repo format', async () => { + process.env.INPUT_REPO = 'invalid-format'; + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining('Invalid repo format') + ); + }); + + test('should use correct owner when repo is specified', async () => { + process.env.INPUT_REPO = 'different-org/repo'; + mockGithub.rest.repos.get.mockResolvedValue({ + data: createRepoData({ name: 'repo' }) + }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('different-org/repo'))).toBe(true); + }); + }); + + describe('Workflow Detection', () => { + test('should run pinact even without .github/workflows directory', async () => { + fs.existsSync.mockReturnValue(false); + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Pinact should run - it determines what files to process + expect(consoleOutput.some(line => line.includes('Running pinact'))).toBe(true); + }); + + test('should process repository with yml workflows', async () => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['workflow.yml', 'another.yml']); + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Running pinact'))).toBe(true); + }); + + test('should process repository with yaml workflows', async () => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['workflow.yaml']); + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Running pinact'))).toBe(true); + }); + }); + + describe('Pinact Execution', () => { + test('should skip PR creation when no changes are made', async () => { + setupExecSyncNoChanges(); + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('No changes made by pinact'))).toBe(true); + expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should create PR when changes are made', async () => { + setupSingleRepoWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Pinact made changes'))).toBe(true); + expect(mockGithub.rest.pulls.create).toHaveBeenCalled(); + }); + + test('should use --config option when config is provided', async () => { + const pinactConfig = 'files:\n - pattern: \'*.ya?ml\'\nupdates:\n - action: actions/checkout'; + + process.env.INPUT_PINACT_CONFIG = pinactConfig; + + setupSingleRepoWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Verify config file was created in temp directory + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('.pinact.yaml'), + pinactConfig, + 'utf-8' + ); + + // Verify pinact was called with --config option + const pinactCalls = execSync.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('pinact') && call[0].includes('run') + ); + expect(pinactCalls.length).toBeGreaterThan(0); + expect(pinactCalls[0][0]).toContain('--config'); + + // Verify config file was cleaned up + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('pinact-config-'), + expect.objectContaining({ recursive: true, force: true }) + ); + }); + }); + + describe('Git Operations', () => { + test('should configure git with custom author', async () => { + process.env.INPUT_GIT_AUTHOR_NAME = 'Custom Bot'; + process.env.INPUT_GIT_AUTHOR_EMAIL = 'bot@example.com'; + + setupSingleRepoWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git config user.name "Custom Bot"'), + expect.any(Object) + ); + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git config user.email "bot@example.com"'), + expect.any(Object) + ); + }); + + test('should create branch with custom name', async () => { + process.env.INPUT_PR_BRANCH_NAME = 'custom-branch'; + + setupSingleRepoWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git checkout -b custom-branch'), + expect.any(Object) + ); + }); + + test('should commit with appropriate message', async () => { + setupSingleRepoWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('chore: update GitHub Actions to use commit hashes'), + expect.any(Object) + ); + }); + }); + + describe('Pull Request Creation', () => { + beforeEach(() => { + setupSingleRepoWithChanges(); + }); + + test('should create PR when none exists', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockGithub.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'test-org', + repo: 'repo1', + head: 'pinact-updates', + base: 'master', + title: expect.stringContaining('chore: update GitHub Actions') + }) + ); + }); + + test('should not create duplicate PR if one exists', async () => { + mockGithub.rest.pulls.list.mockResolvedValue({ + data: [{ + html_url: 'https://github.com/test-org/repo1/pull/1', + number: 1 + }] + }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); + expect(consoleOutput.some(line => line.includes('Pull request already exists'))).toBe(true); + }); + + test('should use default branch when creating PR', async () => { + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockGithub.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + base: 'master' + }) + ); + }); + }); + + describe('Default Branch Detection', () => { + test('should use detected default branch', async () => { + setupExecSyncForDefaultBranch(); + setupGitHubMocksForSingleRepo({ + repoDataOverrides: { default_branch: 'main' } + }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Default branch: main'))).toBe(true); + }); + }); + + describe('Error Handling', () => { + test('should handle repository processing errors gracefully', async () => { + setupErrorMocks('clone'); + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Failed to process'))).toBe(true); + expect(mockCore.setFailed).toHaveBeenCalled(); + expect(mockCore.setFailed.mock.calls[0][0]).toContain('Failed to process'); + }); + + test('should clean up temporary directory on error', async () => { + setupErrorMocks('clone'); + setupSingleRepoNoChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('pinact-'), + expect.objectContaining({ recursive: true, force: true }) + ); + }); + }); + + describe('Summary Output', () => { + test('should display summary with PR count', async () => { + setupMultipleReposWithChanges(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Repositories processed: 2'))).toBe(true); + expect(consoleOutput.some(line => line.includes('Pull requests created/updated: 2'))).toBe(true); + }); + }); + + describe('Dry Run Mode', () => { + beforeEach(() => { + process.env.INPUT_DRY_RUN = 'true'; + setupSingleRepoWithChanges(); + }); + + test('should not push changes in dry run mode', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Dry Run Mode:') && line.includes('ENABLED'))).toBe(true); + expect(consoleOutput.some(line => line.includes('DRY RUN:') && line.includes('Would push'))).toBe(true); + + const pushCalls = execSync.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('git push') + ); + expect(pushCalls).toHaveLength(0); + }); + + test('should not create PR in dry run mode', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('DRY RUN:') && line.includes('Would create PR'))).toBe(true); + expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should display summary with dry run messaging', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Pull requests that would be created: 1'))).toBe(true); + expect(consoleOutput.some(line => line.includes('potential PRs'))).toBe(true); + }); + + test('should still show diff output in dry run mode', async () => { + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Changes made by pinact'))).toBe(true); + expect(consoleOutput.some(line => line.includes('diff --git'))).toBe(true); + }); + }); + + describe('Rate Limiting', () => { + test('should retry on rate limit error', async () => { + const rateLimitError = new Error('rate limit exceeded'); + rateLimitError.status = 429; + + setupSingleRepoNoChanges(); + mockGithub.rest.repos.get + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ data: createRepoData() }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Rate limit hit'))).toBe(true); + expect(mockGithub.rest.repos.get).toHaveBeenCalledTimes(2); + }); + + test('should retry on 403 rate limit error', async () => { + const rateLimitError = new Error('You have exceeded a secondary rate limit'); + rateLimitError.status = 403; + + setupSingleRepoNoChanges(); + mockGithub.rest.repos.get + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ data: createRepoData() }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockGithub.rest.repos.get).toHaveBeenCalledTimes(2); + }); + + test('should use retry-after header when provided', async () => { + jest.useFakeTimers(); + + const rateLimitError = new Error('rate limit exceeded'); + rateLimitError.status = 429; + rateLimitError.response = { + headers: { + 'retry-after': '2' // 2 seconds + } + }; + + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([createRepoListItem()]); + mockGithub.rest.repos.get + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ data: createRepoData() }); + + const actionPromise = runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Fast-forward through the retry delay (2 seconds from retry-after header) + await jest.advanceTimersByTimeAsync(2000); + + await actionPromise; + + jest.useRealTimers(); + + expect(consoleOutput.some(line => line.includes('Rate limit hit, retrying in 2 seconds'))).toBe(true); + expect(mockGithub.rest.repos.get).toHaveBeenCalledTimes(2); + }); + + test('should not retry non-rate-limit errors', async () => { + const otherError = new Error('Not found'); + otherError.status = 404; + + process.env.INPUT_REPO = 'test-org/nonexistent'; + mockGithub.rest.repos.get.mockRejectedValue(otherError); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockGithub.rest.repos.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('Default Branch API Detection', () => { + test('should use detected default branch from API', async () => { + setupExecSyncWithChanges(); + setupGitHubMocksForSingleRepo({ + repoDataOverrides: { default_branch: 'main' } + }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Default branch: main'))).toBe(true); + }); + }); + + describe('Diff Output', () => { + test('should display diff when changes are made', async () => { + setupExecSyncWithDetailedDiff(); + setupGitHubMocksForSingleRepo(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Changes made by pinact'))).toBe(true); + expect(consoleOutput.some(line => line.includes('diff --git'))).toBe(true); + expect(consoleOutput.some(line => line.includes('End of diff'))).toBe(true); + }); + + test('should handle diff errors gracefully', async () => { + setupDiffErrorMocks(); + setupGitHubMocksForSingleRepo(); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Could not display diff'))).toBe(true); + }); + }); + + describe('Additional Error Coverage', () => { + test('should handle pinact execution failure', async () => { + setupErrorMocks('pinact'); + setupGitHubMocksForSingleRepo({ withPullCreate: false }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + const output = consoleOutput.join(' '); + expect(output).toContain('Failed to process'); + expect(output).toContain('Failed to run pinact'); + expect(mockCore.setFailed).toHaveBeenCalled(); + }); + + test('should handle findExistingPR error', async () => { + setupExecSyncWithChanges(); + setupGitHubMocksForSingleRepo(); + mockGithub.rest.pulls.list.mockRejectedValue(new Error('API error')); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Could not check for existing PRs'))).toBe(true); + }); + + test('should handle PR creation failure', async () => { + setupExecSyncWithChanges(); + setupGitHubMocksForSingleRepo(); + mockGithub.rest.pulls.create.mockRejectedValue(new Error('PR creation failed')); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Failed to process'))).toBe(true); + expect(mockCore.setFailed).toHaveBeenCalled(); + }); + + test('should handle non-404 errors when fetching repositories', async () => { + const serverError = new Error('Server error'); + serverError.status = 500; + + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockRejectedValue(serverError); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(mockCore.setFailed).toHaveBeenCalled(); + expect(mockCore.setFailed.mock.calls[0][0]).toContain('Server error'); + }); + + test('should handle cleanup failure after processing', async () => { + setupExecSyncWithChanges(); + setupGitHubMocksForSingleRepo(); + + // Make rmSync fail + fs.rmSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + expect(consoleOutput.some(line => line.includes('Failed to clean up temporary directory'))).toBe(true); + expect(consoleOutput.some(line => line.includes('Permission denied'))).toBe(true); + }); + + test('should throw last error after exhausting all retries', async () => { + // Use fake timers to speed up retry delays + jest.useFakeTimers(); + + const rateLimitError = new Error('rate limit exceeded'); + rateLimitError.status = 429; + + mockGithub.rest.repos.listForOrg.endpoint.merge.mockReturnValue({}); + mockGithub.paginate.mockResolvedValue([createRepoListItem()]); + + // Always fail with rate limit error + let callCount = 0; + mockGithub.rest.repos.get.mockImplementation(async () => { + callCount++; + throw rateLimitError; + }); + + // Start the action + const actionPromise = runPinactAction({ github: mockGithub, context: mockContext, core: mockCore }); + + // Fast-forward through all the retry delays + // There are 3 retries with delays: 1s, 2s, 4s + await jest.advanceTimersByTimeAsync(1000); // First retry + await jest.advanceTimersByTimeAsync(2000); // Second retry + await jest.advanceTimersByTimeAsync(4000); // Third retry + + // Wait for the action to complete + await actionPromise; + + // Restore real timers + jest.useRealTimers(); + + // Should have called 4 times total (initial + 3 retries) + expect(callCount).toBe(4); + + // Should log retry attempts + const output = consoleOutput.join(' '); + expect(output).toContain('Rate limit hit'); + }); + + test('should throw error when default_branch is null', async () => { + await testDefaultBranchError(null); + }); + + test('should throw error when default_branch is undefined', async () => { + await testDefaultBranchError('DELETE'); + }); + + test('should throw error when default_branch is empty string', async () => { + await testDefaultBranchError(''); + }); + }); +});