From 27b7654d5711a80d1ab4c824c6f32913a5f9d749 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 14:53:43 +0000 Subject: [PATCH 1/3] Initial plan From e82305076c23f966b59b9802876ee5ba74398e18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 14:57:12 +0000 Subject: [PATCH 2/3] Initial exploration - understanding the codebase Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index af88475..332452d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulk-github-repo-settings-sync-action", - "version": "1.1.3", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulk-github-repo-settings-sync-action", - "version": "1.1.3", + "version": "1.2.0", "license": "MIT", "dependencies": { "@actions/core": "^1.11.1", From 36d2ca9cfd695ea5483e9c943b94529bf97207e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 15:06:53 +0000 Subject: [PATCH 3/3] feat: add force-sync-rulesets option to delete non-matching rulesets Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- README.md | 24 ++++++ __tests__/index.test.js | 185 +++++++++++++++++++++++++++++++++++++--- action.yml | 4 + package.json | 2 +- src/index.js | 154 +++++++++++++++++++++++++++++++-- 5 files changed, 352 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0502f4e..8c418f1 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,29 @@ repos: For more information on ruleset configuration, see the [GitHub Rulesets documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets). +### Force Sync Rulesets + +By default, syncing rulesets will create or update the specified ruleset by name, but will not delete other rulesets that may exist in the repository. To force sync and delete rulesets that don't match the one being synced, use the `force-sync-rulesets` parameter: + +```yml +- name: Force Sync Repository Rulesets + uses: joshjohanning/bulk-github-repo-settings-sync-action@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + repositories-file: 'repos.yml' + rulesets-file: './config/rulesets/ci-ruleset.json' + force-sync-rulesets: true +``` + +**Behavior with `force-sync-rulesets: true`:** + +- Creates the ruleset if it doesn't exist +- Updates the ruleset if a ruleset with the same name already exists +- **Deletes all other rulesets that don't match the synced ruleset name** +- In dry-run mode, shows which rulesets would be deleted without actually deleting them + +**Use case:** This is useful when you rename a ruleset and want to ensure only the new ruleset exists, or when you want to enforce that repositories have exactly one specific ruleset configuration. + ### Organization-wide Updates ```yml @@ -242,6 +265,7 @@ Output shows what would change: | `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` | | `rulesets-file` | Path to a JSON file containing repository ruleset configuration to sync to target repositories | No | - | +| `force-sync-rulesets` | Delete rulesets that do not match the synced ruleset (force sync to only have the rulesets being synced) | No | `false` | | `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | \* Either `repositories` or `repositories-file` must be provided diff --git a/__tests__/index.test.js b/__tests__/index.test.js index cb5eda8..42d013d 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -43,7 +43,8 @@ const mockOctokit = { getRepoRulesets: jest.fn(), getRepoRuleset: jest.fn(), createRepoRuleset: jest.fn(), - updateRepoRuleset: jest.fn() + updateRepoRuleset: jest.fn(), + deleteRepoRuleset: jest.fn() }, codeScanning: { updateDefaultSetup: jest.fn(), @@ -1213,7 +1214,7 @@ describe('Bulk GitHub Repository Settings Action', () => { data: { id: 456, name: 'ci' } }); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false); expect(result.success).toBe(true); expect(result.ruleset).toBe('updated'); @@ -1260,7 +1261,7 @@ describe('Bulk GitHub Repository Settings Action', () => { data: existingRuleset }); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false); expect(result.success).toBe(true); expect(result.ruleset).toBe('unchanged'); @@ -1282,7 +1283,7 @@ describe('Bulk GitHub Repository Settings Action', () => { mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({ data: [] }); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, true); expect(result.success).toBe(true); expect(result.ruleset).toBe('would-create'); @@ -1314,7 +1315,7 @@ describe('Bulk GitHub Repository Settings Action', () => { data: existingRuleset }); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, true); expect(result.success).toBe(true); expect(result.ruleset).toBe('would-update'); @@ -1325,7 +1326,7 @@ describe('Bulk GitHub Repository Settings Action', () => { }); test('should handle invalid repository format', async () => { - const result = await syncRepositoryRuleset(mockOctokit, 'invalid-repo-format', './ruleset.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'invalid-repo-format', './ruleset.json', false, false); expect(result.success).toBe(false); expect(result.error).toContain('Invalid repository format'); @@ -1336,7 +1337,7 @@ describe('Bulk GitHub Repository Settings Action', () => { throw new Error('ENOENT: no such file or directory'); }); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './nonexistent.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './nonexistent.json', false, false); expect(result.success).toBe(false); expect(result.error).toContain('Failed to read or parse ruleset file'); @@ -1345,7 +1346,7 @@ describe('Bulk GitHub Repository Settings Action', () => { test('should handle invalid JSON in ruleset file', async () => { mockFs.readFileSync.mockReturnValue('{ invalid json }'); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './invalid.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './invalid.json', false, false); expect(result.success).toBe(false); expect(result.error).toContain('Failed to read or parse ruleset file'); @@ -1360,7 +1361,7 @@ describe('Bulk GitHub Repository Settings Action', () => { mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false); expect(result.success).toBe(false); expect(result.error).toContain('Ruleset configuration must include a "name" field'); @@ -1377,7 +1378,7 @@ describe('Bulk GitHub Repository Settings Action', () => { mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); mockOctokit.rest.repos.getRepoRulesets.mockRejectedValue(new Error('API rate limit exceeded')); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false); expect(result.success).toBe(false); expect(result.error).toContain('Failed to sync ruleset'); @@ -1399,10 +1400,172 @@ describe('Bulk GitHub Repository Settings Action', () => { data: { id: 123, name: 'ci' } }); - const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false); + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false); expect(result.success).toBe(true); expect(result.ruleset).toBe('created'); }); + + test('should delete non-matching rulesets when force-sync is enabled', async () => { + const rulesetConfig = { + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + }; + + const existingRulesets = [ + { id: 123, name: 'ci' }, + { id: 456, name: 'ci2' }, + { id: 789, name: 'old-ruleset' } + ]; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); + mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({ + data: existingRulesets + }); + mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({ + data: { + id: 123, + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + } + }); + mockOctokit.rest.repos.deleteRepoRuleset.mockResolvedValue({}); + + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true, false); + + expect(result.success).toBe(true); + expect(result.ruleset).toBe('unchanged'); + expect(result.deletedRulesets).toHaveLength(2); + expect(result.deletedRulesets[0].name).toBe('ci2'); + expect(result.deletedRulesets[0].deleted).toBe(true); + expect(result.deletedRulesets[1].name).toBe('old-ruleset'); + expect(result.deletedRulesets[1].deleted).toBe(true); + expect(mockOctokit.rest.repos.deleteRepoRuleset).toHaveBeenCalledTimes(2); + expect(mockOctokit.rest.repos.deleteRepoRuleset).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + ruleset_id: 456 + }); + expect(mockOctokit.rest.repos.deleteRepoRuleset).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + ruleset_id: 789 + }); + }); + + test('should handle force-sync in dry-run mode', async () => { + const rulesetConfig = { + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + }; + + const existingRulesets = [ + { id: 123, name: 'ci' }, + { id: 456, name: 'ci2' } + ]; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); + mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({ + data: existingRulesets + }); + mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({ + data: { + id: 123, + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + } + }); + + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true, true); + + expect(result.success).toBe(true); + expect(result.ruleset).toBe('unchanged'); + expect(result.deletedRulesets).toHaveLength(1); + expect(result.deletedRulesets[0].name).toBe('ci2'); + expect(result.deletedRulesets[0].wouldDelete).toBe(true); + expect(result.deletedRulesets[0].deleted).toBe(false); + expect(mockOctokit.rest.repos.deleteRepoRuleset).not.toHaveBeenCalled(); + }); + + test('should not delete rulesets when force-sync is disabled', async () => { + const rulesetConfig = { + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + }; + + const existingRulesets = [ + { id: 123, name: 'ci' }, + { id: 456, name: 'ci2' } + ]; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); + mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({ + data: existingRulesets + }); + mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({ + data: { + id: 123, + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + } + }); + + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false); + + expect(result.success).toBe(true); + expect(result.ruleset).toBe('unchanged'); + expect(result.deletedRulesets).toBeUndefined(); + expect(mockOctokit.rest.repos.deleteRepoRuleset).not.toHaveBeenCalled(); + }); + + test('should handle deletion errors gracefully when force-syncing', async () => { + const rulesetConfig = { + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + }; + + const existingRulesets = [ + { id: 123, name: 'ci' }, + { id: 456, name: 'ci2' } + ]; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig)); + mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({ + data: existingRulesets + }); + mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({ + data: { + id: 123, + name: 'ci', + target: 'branch', + enforcement: 'active', + rules: [{ type: 'deletion' }] + } + }); + mockOctokit.rest.repos.deleteRepoRuleset.mockRejectedValue(new Error('Permission denied')); + + const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true, false); + + expect(result.success).toBe(true); + expect(result.ruleset).toBe('unchanged'); + expect(result.deletedRulesets).toHaveLength(1); + expect(result.deletedRulesets[0].name).toBe('ci2'); + expect(result.deletedRulesets[0].deleted).toBe(false); + expect(result.deletedRulesets[0].error).toBe('Permission denied'); + }); }); }); diff --git a/action.yml b/action.yml index cc7d917..6e2c9cb 100644 --- a/action.yml +++ b/action.yml @@ -56,6 +56,10 @@ inputs: rulesets-file: description: 'Path to a JSON file containing repository ruleset configuration to sync to target repositories' required: false + force-sync-rulesets: + description: 'Delete rulesets that do not match the synced ruleset (force sync to only have the rulesets being synced)' + required: false + default: 'false' dry-run: description: 'Preview changes without applying them (logs what would be changed)' required: false diff --git a/package.json b/package.json index c3f761b..8d132ee 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.2.0", + "version": "1.3.0", "type": "module", "author": { "name": "Josh Johanning", diff --git a/src/index.js b/src/index.js index aa862cb..5f7d426 100644 --- a/src/index.js +++ b/src/index.js @@ -691,10 +691,11 @@ export async function syncDependabotYml(octokit, repo, dependabotYmlPath, prTitl * @param {Octokit} octokit - Octokit instance * @param {string} repo - Repository in "owner/repo" format * @param {string} rulesetFilePath - Path to local ruleset JSON file + * @param {boolean} forceSync - Delete rulesets that don't match the synced ruleset * @param {boolean} dryRun - Preview mode without making actual changes * @returns {Promise} Result object */ -export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryRun) { +export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, forceSync, dryRun) { const [owner, repoName] = repo.split('/'); if (!owner || !repoName) { @@ -777,7 +778,8 @@ export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryR if (configsMatch) { core.info(` 📋 Ruleset "${rulesetName}" is already up to date`); - return { + + const result = { repository: repo, success: true, ruleset: 'unchanged', @@ -785,6 +787,53 @@ export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryR message: `Ruleset "${rulesetName}" is already up to date`, dryRun }; + + // Handle force sync - delete rulesets that don't match + if (forceSync) { + const rulesetsToDelete = existingRulesets.filter(r => r.name !== rulesetName); + + if (rulesetsToDelete.length > 0) { + result.deletedRulesets = []; + + for (const rulesetToDelete of rulesetsToDelete) { + if (dryRun) { + core.info(` 🗑️ Would delete ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id})`); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: false, + wouldDelete: true + }); + } else { + try { + await octokit.rest.repos.deleteRepoRuleset({ + owner, + repo: repoName, + ruleset_id: rulesetToDelete.id + }); + core.info(` 🗑️ Deleted ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id})`); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: true + }); + } catch (deleteError) { + core.warning( + ` ⚠️ Failed to delete ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id}): ${deleteError.message}` + ); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: false, + error: deleteError.message + }); + } + } + } + } + } + + return result; } if (dryRun) { @@ -808,7 +857,7 @@ export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryR core.info(` 📋 Updated ruleset "${rulesetName}" (ID: ${existingRuleset.id})`); - return { + const result = { repository: repo, success: true, ruleset: 'updated', @@ -816,6 +865,53 @@ export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryR message: `Updated ruleset "${rulesetName}" (ID: ${existingRuleset.id})`, dryRun }; + + // Handle force sync - delete rulesets that don't match + if (forceSync) { + const rulesetsToDelete = existingRulesets.filter(r => r.name !== rulesetName); + + if (rulesetsToDelete.length > 0) { + result.deletedRulesets = []; + + for (const rulesetToDelete of rulesetsToDelete) { + if (dryRun) { + core.info(` 🗑️ Would delete ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id})`); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: false, + wouldDelete: true + }); + } else { + try { + await octokit.rest.repos.deleteRepoRuleset({ + owner, + repo: repoName, + ruleset_id: rulesetToDelete.id + }); + core.info(` 🗑️ Deleted ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id})`); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: true + }); + } catch (deleteError) { + core.warning( + ` ⚠️ Failed to delete ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id}): ${deleteError.message}` + ); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: false, + error: deleteError.message + }); + } + } + } + } + } + + return result; } if (dryRun) { @@ -837,7 +933,7 @@ export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryR core.info(` 📋 Created ruleset "${rulesetName}" (ID: ${newRuleset.id})`); - return { + const result = { repository: repo, success: true, ruleset: 'created', @@ -845,6 +941,53 @@ export async function syncRepositoryRuleset(octokit, repo, rulesetFilePath, dryR message: `Created ruleset "${rulesetName}" (ID: ${newRuleset.id})`, dryRun }; + + // Handle force sync - delete rulesets that don't match + if (forceSync) { + const rulesetsToDelete = existingRulesets.filter(r => r.name !== rulesetName); + + if (rulesetsToDelete.length > 0) { + result.deletedRulesets = []; + + for (const rulesetToDelete of rulesetsToDelete) { + if (dryRun) { + core.info(` 🗑️ Would delete ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id})`); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: false, + wouldDelete: true + }); + } else { + try { + await octokit.rest.repos.deleteRepoRuleset({ + owner, + repo: repoName, + ruleset_id: rulesetToDelete.id + }); + core.info(` 🗑️ Deleted ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id})`); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: true + }); + } catch (deleteError) { + core.warning( + ` ⚠️ Failed to delete ruleset "${rulesetToDelete.name}" (ID: ${rulesetToDelete.id}): ${deleteError.message}` + ); + result.deletedRulesets.push({ + name: rulesetToDelete.name, + id: rulesetToDelete.id, + deleted: false, + error: deleteError.message + }); + } + } + } + } + } + + return result; } catch (error) { return { repository: repo, @@ -916,6 +1059,7 @@ export async function run() { // Get rulesets settings const rulesetsFile = getInput('rulesets-file'); + const forceSyncRulesets = getBooleanInput('force-sync-rulesets'); core.info('Starting Bulk GitHub Repository Settings Action...'); @@ -1068,7 +1212,7 @@ export async function run() { // Sync repository ruleset if specified if (repoRulesetsFile) { core.info(` 📋 Checking repository ruleset...`); - const rulesetResult = await syncRepositoryRuleset(octokit, repo, repoRulesetsFile, dryRun); + const rulesetResult = await syncRepositoryRuleset(octokit, repo, repoRulesetsFile, forceSyncRulesets, dryRun); // Add ruleset result to the main result result.rulesetSync = rulesetResult;