diff --git a/README.md b/README.md index 9105263..933fa6a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Update repository settings in bulk across multiple GitHub repositories. - 📊 Enable default CodeQL code scanning - 🏷️ Manage repository topics - � **Sync dependabot.yml files** across repositories via pull requests +- 📝 **Sync .gitignore files** across repositories via pull requests (preserves repo-specific entries) - �📝 Support multiple repository input methods (comma-separated, YAML file, or all org repos) - 🔍 **Dry-run mode** with change preview and intelligent change detection - 📋 **Per-repository overrides** via YAML configuration @@ -103,6 +104,58 @@ repos: - PRs are created/updated using the GitHub API so commits are verified - Updates existing open PRs instead of creating duplicates +### Syncing .gitignore Configuration + +Sync a `.gitignore` file to `.gitignore` in target repositories via pull requests: + +```yml +- name: Sync .gitignore Config + uses: joshjohanning/bulk-github-repo-settings-sync-action@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + repositories-file: 'repos.yml' + gitignore: './config/gitignore/.gitignore' + gitignore-pr-title: 'chore: update .gitignore' +``` + +Or with repo-specific overrides in `repos.yml`: + +```yaml +repos: + - repo: owner/repo1 + gitignore: './config/gitignore/node.gitignore' + - repo: owner/repo2 + gitignore: './config/gitignore/python.gitignore' + - repo: owner/repo3 + gitignore: './.gitignore' # use the same config that this repo is using +``` + +**Behavior:** + +- If `.gitignore` doesn't exist, it creates it and opens a PR +- If it exists but differs, it updates it via PR +- **Repository-specific entries are preserved**: Content after a `# Repository-specific entries (preserved during sync)` marker is kept intact +- If content is identical, no PR is created +- PRs are created/updated using the GitHub API so commits are verified +- Updates existing open PRs instead of creating duplicates + +**Example: Preserving repo-specific entries** + +In your target repository's `.gitignore`, you can add repository-specific entries that will be preserved during syncs: + +```gitignore +# Standard entries (synced from source) +node_modules/ +dist/ +*.log + +# Repository-specific entries (preserved during sync) +scanresults.json +twistlock-*.md +``` + +When the sync runs, it will update the standard entries from the source file while keeping `scanresults.json` and `twistlock-*.md` intact. + ### Organization-wide Updates ```yml @@ -161,6 +214,8 @@ Output shows what would change: | `topics` | Comma-separated list of topics to set on repositories (replaces existing topics) | No | - | | `dependabot-yml` | Path to a dependabot.yml file to sync to `.github/dependabot.yml` in target repositories | No | - | | `dependabot-pr-title` | Title for pull requests when updating dependabot.yml | No | `chore: update dependabot.yml` | +| `gitignore` | Path to a .gitignore file to sync to `.gitignore` in target repositories (preserves repo-specific content at end) | No | - | +| `gitignore-pr-title` | Title for pull requests when updating .gitignore | No | `chore: update .gitignore` | | `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | \* Either `repositories` or `repositories-file` must be provided @@ -181,8 +236,8 @@ For better security and rate limits, use a GitHub App: 1. Create a GitHub App with the following permissions: - **Repository Administration**: Read and write (required for updating repository settings) - - **Contents**: Read and write (required if syncing `dependabot.yml`) - - **Pull Requests**: Read and write (required if syncing `dependabot.yml`) + - **Contents**: Read and write (required if syncing `dependabot.yml` or `.gitignore`) + - **Pull Requests**: Read and write (required if syncing `dependabot.yml` or `.gitignore`) 2. Install it to your organization/repositories 3. Add `APP_ID` and `APP_PRIVATE_KEY` as repository secrets @@ -238,6 +293,7 @@ repos: - repo: owner/repo3 enable-default-code-scanning: false dependabot-yml: './github/dependabot-configs/custom-dependabot.yml' + gitignore: './config/gitignore/node.gitignore' ``` **Priority:** Repository-specific settings override global defaults from action inputs. @@ -248,6 +304,8 @@ repos: - Topics **replace** all existing repository topics - Dependabot.yml syncing creates pull requests for review before merging - Dependabot.yml PRs use the GitHub API ensuring verified commits +- .gitignore syncing creates pull requests and preserves repo-specific entries after a marker comment +- .gitignore PRs use the GitHub API ensuring verified commits - Failed updates are logged as warnings but don't fail the action - **Access denied repositories are skipped with warnings** - ensure your GitHub App has: - Repository Administration permissions diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 51844c9..d86d347 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -85,7 +85,8 @@ const { default: run, parseRepositories, updateRepositorySettings, - syncDependabotYml + syncDependabotYml, + syncGitignore } = await import('../src/index.js'); describe('Bulk GitHub Repository Settings Action', () => { @@ -627,7 +628,7 @@ describe('Bulk GitHub Repository Settings Action', () => { await run(); expect(mockCore.setFailed).toHaveBeenCalledWith( - 'Action failed with error: At least one repository setting must be specified (or enable-default-code-scanning must be true, or topics must be provided, or dependabot-yml must be specified)' + 'Action failed with error: At least one repository setting must be specified (or enable-default-code-scanning must be true, or topics must be provided, or dependabot-yml must be specified, or gitignore must be specified)' ); }); @@ -1074,4 +1075,315 @@ describe('Bulk GitHub Repository Settings Action', () => { expect(result.error).toContain('Failed to sync dependabot.yml'); }); }); + + describe('syncGitignore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOctokit.rest.repos.get.mockClear(); + mockOctokit.rest.repos.getContent.mockClear(); + mockOctokit.rest.repos.createOrUpdateFileContents.mockClear(); + mockOctokit.rest.git.getRef.mockClear(); + mockOctokit.rest.git.createRef.mockClear(); + mockOctokit.rest.git.updateRef.mockClear(); + mockOctokit.rest.pulls.list.mockClear(); + mockOctokit.rest.pulls.create.mockClear(); + mockOctokit.rest.pulls.update.mockClear(); + }); + + test('should create .gitignore when it does not exist', async () => { + const testGitignoreContent = 'node_modules/\ndist/\n*.log'; + + mockFs.readFileSync.mockReturnValue(testGitignoreContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + // File does not exist + mockOctokit.rest.repos.getContent.mockRejectedValue({ + status: 404 + }); + + // No existing PRs + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + // Branch doesn't exist + mockOctokit.rest.git.getRef + .mockRejectedValueOnce({ status: 404 }) // Branch check + .mockResolvedValueOnce({ + // Default branch ref + data: { object: { sha: 'abc123' } } + }); + + mockOctokit.rest.git.createRef.mockResolvedValue({}); + mockOctokit.rest.repos.createOrUpdateFileContents.mockResolvedValue({}); + mockOctokit.rest.pulls.create.mockResolvedValue({ + data: { + number: 42, + html_url: 'https://github.com/owner/repo/pull/42' + } + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: add .gitignore', false); + + expect(result.success).toBe(true); + expect(result.gitignore).toBe('created'); + expect(result.prNumber).toBe(42); + expect(mockOctokit.rest.git.createRef).toHaveBeenCalled(); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'owner', + repo: 'repo', + path: '.gitignore', + branch: 'gitignore-sync' + }) + ); + expect(mockOctokit.rest.pulls.create).toHaveBeenCalled(); + }); + + test('should update .gitignore when content differs', async () => { + const newContent = 'node_modules/\ndist/\n*.log\n.env'; + const oldContent = 'node_modules/\ndist/\n*.log'; + + mockFs.readFileSync.mockReturnValue(newContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + // File exists with different content + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'file-sha-456', + content: Buffer.from(oldContent).toString('base64') + } + }); + + // No existing PRs + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + // Branch doesn't exist + mockOctokit.rest.git.getRef.mockRejectedValueOnce({ status: 404 }).mockResolvedValueOnce({ + data: { object: { sha: 'abc123' } } + }); + + mockOctokit.rest.git.createRef.mockResolvedValue({}); + mockOctokit.rest.repos.createOrUpdateFileContents.mockResolvedValue({}); + mockOctokit.rest.pulls.create.mockResolvedValue({ + data: { + number: 43, + html_url: 'https://github.com/owner/repo/pull/43' + } + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: update .gitignore', false); + + expect(result.success).toBe(true); + expect(result.gitignore).toBe('updated'); + expect(result.prNumber).toBe(43); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith( + expect.objectContaining({ + sha: 'file-sha-456' + }) + ); + }); + + test('should preserve repo-specific content at end', async () => { + const sourceContent = 'node_modules/\ndist/\n*.log\n.env'; + const existingContent = + 'node_modules/\ndist/\n*.log\n\n# Repository-specific entries (preserved during sync)\nscanresults.json\ntwistlock-*.md'; + + mockFs.readFileSync.mockReturnValue(sourceContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + // File exists with repo-specific content + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'file-sha-789', + content: Buffer.from(existingContent).toString('base64') + } + }); + + // No existing PRs + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + // Branch doesn't exist + mockOctokit.rest.git.getRef.mockRejectedValueOnce({ status: 404 }).mockResolvedValueOnce({ + data: { object: { sha: 'abc123' } } + }); + + mockOctokit.rest.git.createRef.mockResolvedValue({}); + mockOctokit.rest.repos.createOrUpdateFileContents.mockResolvedValue({}); + mockOctokit.rest.pulls.create.mockResolvedValue({ + data: { + number: 44, + html_url: 'https://github.com/owner/repo/pull/44' + } + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: update .gitignore', false); + + expect(result.success).toBe(true); + expect(result.gitignore).toBe('updated'); + + // Check that the content contains both the base and repo-specific parts + const createdContent = mockOctokit.rest.repos.createOrUpdateFileContents.mock.calls[0][0].content; + const decodedContent = Buffer.from(createdContent, 'base64').toString('utf8'); + expect(decodedContent).toContain('node_modules/'); + expect(decodedContent).toContain('.env'); + expect(decodedContent).toContain('# Repository-specific entries (preserved during sync)'); + expect(decodedContent).toContain('scanresults.json'); + expect(decodedContent).toContain('twistlock-*.md'); + }); + + test('should not create PR when content is unchanged', async () => { + const content = 'node_modules/\ndist/\n*.log'; + + mockFs.readFileSync.mockReturnValue(content); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + // File exists with same content + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'file-sha-789', + content: Buffer.from(content).toString('base64') + } + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: update .gitignore', false); + + expect(result.success).toBe(true); + expect(result.gitignore).toBe('unchanged'); + expect(result.message).toContain('already up to date'); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + }); + + test('should return existing PR when one exists', async () => { + const newContent = 'node_modules/\ndist/\n*.log\n.env'; + const oldContent = 'node_modules/\ndist/\n*.log'; + + mockFs.readFileSync.mockReturnValue(newContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + // File exists with different content + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'file-sha-456', + content: Buffer.from(oldContent).toString('base64') + } + }); + + // Existing PR + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [ + { + number: 100, + html_url: 'https://github.com/owner/repo/pull/100' + } + ] + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: update .gitignore', false); + + expect(result.success).toBe(true); + expect(result.gitignore).toBe('pr-exists'); + expect(result.prNumber).toBe(100); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should handle dry-run mode', async () => { + const newContent = 'node_modules/\ndist/\n*.log\n.env'; + const oldContent = 'node_modules/\ndist/\n*.log'; + + mockFs.readFileSync.mockReturnValue(newContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'file-sha-456', + content: Buffer.from(oldContent).toString('base64') + } + }); + + // No existing PRs + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: update .gitignore', true); + + expect(result.success).toBe(true); + expect(result.gitignore).toBe('would-update'); + expect(result.dryRun).toBe(true); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should handle invalid repository format', async () => { + const result = await syncGitignore( + mockOctokit, + 'invalid-format', + './.gitignore', + 'chore: update .gitignore', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid repository format'); + }); + + test('should handle missing .gitignore file', async () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './nonexistent', 'chore: update .gitignore', false); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to read .gitignore file'); + }); + + test('should handle API errors', async () => { + mockFs.readFileSync.mockReturnValue('node_modules/'); + + mockOctokit.rest.repos.get.mockRejectedValue(new Error('API rate limit exceeded')); + + const result = await syncGitignore(mockOctokit, 'owner/repo', './.gitignore', 'chore: update .gitignore', false); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to sync .gitignore'); + }); + }); }); diff --git a/action.yml b/action.yml index 5dbd4c0..01cb5e4 100644 --- a/action.yml +++ b/action.yml @@ -53,6 +53,13 @@ inputs: description: 'Title for pull requests when updating dependabot.yml' required: false default: 'chore: update dependabot.yml' + gitignore: + description: 'Path to a .gitignore file to sync to .gitignore in target repositories (preserves repo-specific content at end)' + required: false + gitignore-pr-title: + description: 'Title for pull requests when updating .gitignore' + required: false + default: 'chore: update .gitignore' dry-run: description: 'Preview changes without applying them (logs what would be changed)' required: false diff --git a/example-gitignore b/example-gitignore new file mode 100644 index 0000000..b59d2c7 --- /dev/null +++ b/example-gitignore @@ -0,0 +1,24 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +*.tgz + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS generated files +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local diff --git a/example-repos-with-overrides.yml b/example-repos-with-overrides.yml index 0f00278..4de56a7 100644 --- a/example-repos-with-overrides.yml +++ b/example-repos-with-overrides.yml @@ -8,5 +8,6 @@ repos: allow-update-branch: false enable-default-code-scanning: false topics: 'javascript,github-actions,automation,different' + gitignore: './example-gitignore' - repo: joshjohanning/other-repo # This repo will use the defaults from the action inputs diff --git a/package-lock.json b/package-lock.json index 1cff2a6..02b105c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1548,7 +1547,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -1713,7 +1711,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.5.tgz", "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2102,7 +2099,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -2633,7 +2629,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3081,7 +3076,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3736,7 +3730,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5563,7 +5556,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -6990,7 +6982,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8016,7 +8007,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index a5182d9..e766538 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bulk-github-repo-settings-sync-action", "description": "Update repository settings in bulk for multiple GitHub repositories", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "author": { "name": "Josh Johanning", diff --git a/src/index.js b/src/index.js index 1ff691f..8bc39c3 100644 --- a/src/index.js +++ b/src/index.js @@ -682,6 +682,273 @@ export async function syncDependabotYml(octokit, repo, dependabotYmlPath, prTitl } } +/** + * Sync .gitignore file to target repository + * @param {Octokit} octokit - Octokit instance + * @param {string} repo - Repository in "owner/repo" format + * @param {string} gitignorePath - Path to local .gitignore file + * @param {string} prTitle - Title for the pull request + * @param {boolean} dryRun - Preview mode without making actual changes + * @returns {Promise} Result object + */ +export async function syncGitignore(octokit, repo, gitignorePath, prTitle, dryRun) { + const [owner, repoName] = repo.split('/'); + const targetPath = '.gitignore'; + const repoSpecificMarker = '# Repository-specific entries (preserved during sync)'; + + if (!owner || !repoName) { + return { + repository: repo, + success: false, + error: 'Invalid repository format. Expected "owner/repo"', + dryRun + }; + } + + try { + // Read the source .gitignore file + let sourceContent; + try { + sourceContent = fs.readFileSync(gitignorePath, 'utf8'); + } catch (error) { + return { + repository: repo, + success: false, + error: `Failed to read .gitignore file at ${gitignorePath}: ${error.message}`, + dryRun + }; + } + + // Get default branch + const { data: repoData } = await octokit.rest.repos.get({ + owner, + repo: repoName + }); + const defaultBranch = repoData.default_branch; + + // Check if .gitignore exists in the target repo + let existingSha = null; + let existingContent = null; + let repoSpecificContent = ''; + + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo: repoName, + path: targetPath, + ref: defaultBranch + }); + existingSha = data.sha; + existingContent = Buffer.from(data.content, 'base64').toString('utf8'); + + // Extract repo-specific content (after the marker) + const markerIndex = existingContent.indexOf(repoSpecificMarker); + if (markerIndex !== -1) { + repoSpecificContent = existingContent.substring(markerIndex); + } + } catch (error) { + if (error.status === 404) { + // File doesn't exist - this is fine, we'll create it + core.info(` 📄 ${targetPath} does not exist in ${repo}, will create it`); + } else { + throw error; + } + } + + // Build the new content + let newContent = sourceContent.trim(); + + // If there's repo-specific content, append it + if (repoSpecificContent) { + // Ensure there's a blank line before the repo-specific section + if (!newContent.endsWith('\n')) { + newContent += '\n'; + } + if (!newContent.endsWith('\n\n')) { + newContent += '\n'; + } + newContent += repoSpecificContent; + } + + // Ensure file ends with a newline + if (!newContent.endsWith('\n')) { + newContent += '\n'; + } + + // Compare content + const needsUpdate = !existingContent || existingContent.trim() !== newContent.trim(); + + if (!needsUpdate) { + return { + repository: repo, + success: true, + gitignore: 'unchanged', + message: `${targetPath} is already up to date`, + dryRun + }; + } + + // Check if there's already an open PR for this update + const branchName = 'gitignore-sync'; + let existingPR = null; + + try { + const { data: pulls } = await octokit.rest.pulls.list({ + owner, + repo: repoName, + state: 'open', + head: `${owner}:${branchName}` + }); + + if (pulls.length > 0) { + existingPR = pulls[0]; + core.info(` 🔄 Found existing open PR #${existingPR.number} for ${targetPath}`); + } + } catch (error) { + // Non-fatal, continue + core.warning(` ⚠️ Could not check for existing PRs: ${error.message}`); + } + + // If there's already an open PR, don't create/update another one + if (existingPR) { + return { + repository: repo, + success: true, + gitignore: 'pr-exists', + message: `Open PR #${existingPR.number} already exists for ${targetPath}`, + prNumber: existingPR.number, + prUrl: existingPR.html_url, + dryRun + }; + } + + if (dryRun) { + return { + repository: repo, + success: true, + gitignore: existingContent ? 'would-update' : 'would-create', + message: existingContent ? `Would update ${targetPath} via PR` : `Would create ${targetPath} via PR`, + dryRun + }; + } + + // Create or get reference to the branch + let branchExists = false; + try { + await octokit.rest.git.getRef({ + owner, + repo: repoName, + ref: `heads/${branchName}` + }); + branchExists = true; + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + // Get the SHA of the default branch to create new branch from + const { data: defaultRef } = await octokit.rest.git.getRef({ + owner, + repo: repoName, + ref: `heads/${defaultBranch}` + }); + + if (!branchExists) { + // Create new branch + await octokit.rest.git.createRef({ + owner, + repo: repoName, + ref: `refs/heads/${branchName}`, + sha: defaultRef.object.sha + }); + core.info(` 🌿 Created branch ${branchName}`); + } else { + // Update existing branch to latest from default branch + await octokit.rest.git.updateRef({ + owner, + repo: repoName, + ref: `heads/${branchName}`, + sha: defaultRef.object.sha, + force: true + }); + core.info(` 🌿 Updated branch ${branchName}`); + } + + // Create or update the file + const commitMessage = existingContent ? `chore: update ${targetPath}` : `chore: add ${targetPath}`; + + await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo: repoName, + path: targetPath, + message: commitMessage, + content: Buffer.from(newContent).toString('base64'), + branch: branchName, + sha: existingSha || undefined + }); + + core.info(` ✍️ Committed changes to ${targetPath}`); + + // Prepare PR body content + let prBody; + if (existingContent) { + prBody = `This PR updates \`.gitignore\` to the latest version.\n\n**Changes:**\n- Updated .gitignore configuration`; + if (repoSpecificContent) { + prBody += `\n- Repository-specific entries have been preserved`; + } + } else { + prBody = `This PR adds \`.gitignore\` to the repository.\n\n**Changes:**\n- Added .gitignore configuration`; + } + + // Create or update PR + let prNumber; + if (existingPR) { + // Update existing PR + await octokit.rest.pulls.update({ + owner, + repo: repoName, + pull_number: existingPR.number, + title: prTitle, + body: prBody + }); + prNumber = existingPR.number; + core.info(` 🔄 Updated existing PR #${prNumber}`); + } else { + // Create new PR + const { data: pr } = await octokit.rest.pulls.create({ + owner, + repo: repoName, + title: prTitle, + head: branchName, + base: defaultBranch, + body: prBody + }); + prNumber = pr.number; + core.info(` 📬 Created PR #${prNumber}: ${pr.html_url}`); + } + + return { + repository: repo, + success: true, + gitignore: existingContent ? 'updated' : 'created', + prNumber, + prUrl: `https://github.com/${owner}/${repoName}/pull/${prNumber}`, + message: existingContent + ? `Updated ${targetPath} via PR #${prNumber}` + : `Created ${targetPath} via PR #${prNumber}`, + dryRun + }; + } catch (error) { + return { + repository: repo, + success: false, + error: `Failed to sync .gitignore: ${error.message}`, + dryRun + }; + } +} + /** * Check if a repository result has any changes * @param {Object} result - Repository update result object @@ -695,7 +962,11 @@ function hasRepositoryChanges(result) { (result.dependabotSync && result.dependabotSync.success && result.dependabotSync.dependabotYml && - result.dependabotSync.dependabotYml !== 'unchanged') + result.dependabotSync.dependabotYml !== 'unchanged') || + (result.gitignoreSync && + result.gitignoreSync.success && + result.gitignoreSync.gitignore && + result.gitignoreSync.gitignore !== 'unchanged') ); } @@ -735,7 +1006,11 @@ export async function run() { // Get dependabot.yml settings const dependabotYml = getInput('dependabot-yml'); - const prTitle = getInput('dependabot-pr-title') || 'chore: update dependabot.yml'; + const dependabotPrTitle = getInput('dependabot-pr-title') || 'chore: update dependabot.yml'; + + // Get .gitignore settings + const gitignore = getInput('gitignore'); + const gitignorePrTitle = getInput('gitignore-pr-title') || 'chore: update .gitignore'; core.info('Starting Bulk GitHub Repository Settings Action...'); @@ -749,10 +1024,14 @@ export async function run() { // Check if any settings are specified const hasSettings = - Object.values(settings).some(value => value !== null) || enableCodeScanning || topics !== null || dependabotYml; + Object.values(settings).some(value => value !== null) || + enableCodeScanning || + topics !== null || + dependabotYml || + gitignore; if (!hasSettings) { throw new Error( - 'At least one repository setting must be specified (or enable-default-code-scanning must be true, or topics must be provided, or dependabot-yml must be specified)' + 'At least one repository setting must be specified (or enable-default-code-scanning must be true, or topics must be provided, or dependabot-yml must be specified, or gitignore must be specified)' ); } @@ -776,6 +1055,9 @@ export async function run() { if (dependabotYml) { core.info(`Dependabot.yml will be synced from: ${dependabotYml}`); } + if (gitignore) { + core.info(`.gitignore will be synced from: ${gitignore}`); + } // Update repositories const results = []; @@ -838,6 +1120,12 @@ export async function run() { repoDependabotYml = repoConfig['dependabot-yml']; } + // Handle repo-specific .gitignore + let repoGitignore = gitignore; + if (repoConfig['gitignore'] !== undefined) { + repoGitignore = repoConfig['gitignore']; + } + const result = await updateRepositorySettings( octokit, repo, @@ -851,7 +1139,7 @@ export async function run() { // Sync dependabot.yml if specified if (repoDependabotYml) { core.info(` 📦 Syncing dependabot.yml...`); - const dependabotResult = await syncDependabotYml(octokit, repo, repoDependabotYml, prTitle, dryRun); + const dependabotResult = await syncDependabotYml(octokit, repo, repoDependabotYml, dependabotPrTitle, dryRun); // Add dependabot result to the main result result.dependabotSync = dependabotResult; @@ -872,6 +1160,30 @@ export async function run() { } } + // Sync .gitignore if specified + if (repoGitignore) { + core.info(` 📝 Syncing .gitignore...`); + const gitignoreResult = await syncGitignore(octokit, repo, repoGitignore, gitignorePrTitle, dryRun); + + // Add gitignore result to the main result + result.gitignoreSync = gitignoreResult; + + if (gitignoreResult.success) { + if (gitignoreResult.gitignore === 'unchanged') { + core.info(` 📝 ${gitignoreResult.message}`); + } else if (dryRun) { + core.info(` 📝 ${gitignoreResult.message}`); + } else { + core.info(` 📝 ${gitignoreResult.message}`); + if (gitignoreResult.prUrl) { + core.info(` 🔗 PR URL: ${gitignoreResult.prUrl}`); + } + } + } else { + core.warning(` ⚠️ ${gitignoreResult.error}`); + } + } + if (result.success) { successCount++; if (dryRun) {