From bb7a449f0e30eea88b1abb20ac2e01f643fff847 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:31:28 +0000 Subject: [PATCH 1/6] Initial plan From 71ef4142152c80fd4ac5bbcdab5a6d8dacfab87c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:33:57 +0000 Subject: [PATCH 2/6] Initial analysis and planning Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- package-lock.json | 10 ---------- 1 file changed, 10 deletions(-) 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" From 6b3375f8bcbb3d58dd91f1ea5593ae9e2e31f7bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:43:49 +0000 Subject: [PATCH 3/6] feat: add package.json devDependencies and scripts sync Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- README.md | 71 +++++- __tests__/index.test.js | 529 +++++++++++++++++++++++++++++++++++++++- action.yml | 15 ++ package.json | 2 +- src/index.js | 479 +++++++++++++++++++++++++++++++++++- 5 files changed, 1086 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9105263..cefc934 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ Update repository settings in bulk across multiple GitHub repositories. - 🔄 Configure pull request branch update suggestions - 📊 Enable default CodeQL code scanning - 🏷️ Manage repository topics -- � **Sync dependabot.yml files** across repositories via pull requests -- �📝 Support multiple repository input methods (comma-separated, YAML file, or all org repos) +- 📦 **Sync dependabot.yml files** across repositories via pull requests +- 📦 **Sync package.json devDependencies and npm scripts** across repositories via pull requests +- 📝 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 - 📊 **Comprehensive logging** showing before/after values for all changes @@ -103,6 +104,59 @@ repos: - PRs are created/updated using the GitHub API so commits are verified - Updates existing open PRs instead of creating duplicates +### Syncing Package.json DevDependencies and Scripts + +Sync `devDependencies` and/or `scripts` from a template `package.json` file to target repositories via pull requests: + +```yml +- name: Sync Package.json Dependencies + uses: joshjohanning/bulk-github-repo-settings-sync-action@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + repositories-file: 'repos.yml' + package-json-file: './templates/package.json' + sync-dev-dependencies: true + sync-scripts: true + package-json-pr-title: 'chore: update package.json' +``` + +You can sync just devDependencies or just scripts: + +```yml +- name: Sync Only DevDependencies + uses: joshjohanning/bulk-github-repo-settings-sync-action@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + repositories: 'owner/repo1,owner/repo2' + package-json-file: './templates/package.json' + sync-dev-dependencies: true + sync-scripts: false +``` + +Or with repo-specific overrides in `repos.yml`: + +```yaml +repos: + - repo: owner/repo1 + package-json-file: './templates/node-project.json' + sync-dev-dependencies: true + sync-scripts: true + - repo: owner/repo2 + package-json-file: './templates/typescript-project.json' + sync-dev-dependencies: true + sync-scripts: false +``` + +**Behavior:** + +- Only updates existing `package.json` files (does not create new ones) +- Replaces entire `devDependencies` and/or `scripts` sections from the template +- Automatically runs `npm install` to update `package-lock.json` (when syncing devDependencies) +- Both files are committed in separate commits in the same PR +- PRs are created/updated using the GitHub API so commits are verified +- If content is identical, no PR is created +- Updates existing open PRs instead of creating duplicates + ### Organization-wide Updates ```yml @@ -161,6 +215,10 @@ 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` | +| `package-json-file` | Path to a package.json file to use as source for syncing devDependencies and/or scripts | No | - | +| `sync-dev-dependencies` | Sync devDependencies from package-json-file to target repositories | No | `false` | +| `sync-scripts` | Sync npm scripts from package-json-file to target repositories | No | `false` | +| `package-json-pr-title` | Title for pull requests when updating package.json | No | `chore: update package.json` | | `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | \* Either `repositories` or `repositories-file` must be provided @@ -238,6 +296,10 @@ repos: - repo: owner/repo3 enable-default-code-scanning: false dependabot-yml: './github/dependabot-configs/custom-dependabot.yml' + - repo: owner/repo4 + package-json-file: './templates/typescript-project.json' + sync-dev-dependencies: true + sync-scripts: true ``` **Priority:** Repository-specific settings override global defaults from action inputs. @@ -248,9 +310,14 @@ 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 +- Package.json syncing **replaces** the entire `devDependencies` and/or `scripts` sections +- Package.json syncing only updates existing package.json files (does not create new ones) +- Package-lock.json is automatically updated when syncing devDependencies - 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 + - Contents: Read and write (if syncing dependabot.yml or package.json) + - Pull Requests: Read and write (if syncing dependabot.yml or package.json) - Is installed on all target repositories - CodeQL scanning may not be available for all languages diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 51844c9..d6a0da3 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -63,7 +63,11 @@ const mockOctokit = { // Mock fs module const mockFs = { - readFileSync: jest.fn() + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + existsSync: jest.fn(), + mkdtempSync: jest.fn(() => '/tmp/npm-install-test'), + rmSync: jest.fn() }; // Mock yaml module @@ -71,6 +75,21 @@ const mockYaml = { load: jest.fn() }; +// Mock child_process module +const mockChildProcess = { + execSync: jest.fn() +}; + +// Mock path module +const mockPath = { + join: jest.fn((...args) => args.join('/')) +}; + +// Mock os module +const mockOs = { + tmpdir: jest.fn(() => '/tmp') +}; + // Mock the modules before importing the main module jest.unstable_mockModule('@actions/core', () => mockCore); jest.unstable_mockModule('@actions/github', () => mockGithub); @@ -79,6 +98,9 @@ jest.unstable_mockModule('@octokit/rest', () => ({ })); jest.unstable_mockModule('fs', () => mockFs); jest.unstable_mockModule('js-yaml', () => mockYaml); +jest.unstable_mockModule('child_process', () => mockChildProcess); +jest.unstable_mockModule('path', () => mockPath); +jest.unstable_mockModule('os', () => mockOs); // Import the main module and helper functions after mocking const { @@ -627,7 +649,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 package-json-file with sync-dev-dependencies/sync-scripts must be specified)' ); }); @@ -771,6 +793,509 @@ describe('Bulk GitHub Repository Settings Action', () => { }); }); + describe('syncPackageJson', () => { + 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 sync devDependencies when requested', async () => { + const sourcePackageJson = { + name: 'source-package', + devDependencies: { + eslint: '^9.0.0', + prettier: '^3.0.0' + }, + scripts: { + test: 'jest' + } + }; + + const targetPackageJson = { + name: 'target-package', + devDependencies: { + eslint: '^8.0.0', + jest: '^29.0.0' + }, + scripts: { + test: 'jest' + } + }; + + const packageLockJson = { + name: 'target-package', + lockfileVersion: 3, + packages: {} + }; + + // Mock file system operations for npm install + mockFs.readFileSync + .mockReturnValueOnce(JSON.stringify(sourcePackageJson)) // Reading source package.json + .mockReturnValueOnce(JSON.stringify(packageLockJson)); // Reading generated package-lock.json + + mockFs.existsSync.mockReturnValue(true); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent + .mockResolvedValueOnce({ + // First call: get package.json + data: { + sha: 'package-sha-123', + content: Buffer.from(JSON.stringify(targetPackageJson)).toString('base64') + } + }) + .mockResolvedValueOnce({ + // Second call: get package-lock.json + data: { + sha: 'package-lock-sha-123', + content: Buffer.from(JSON.stringify(packageLockJson)).toString('base64') + } + }); + + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + 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: 10, + html_url: 'https://github.com/owner/repo/pull/10' + } + }); + + mockChildProcess.execSync.mockReturnValue(undefined); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + true, // syncDevDependencies + false, // syncScripts + 'chore: update package.json', + false + ); + + expect(result.success).toBe(true); + expect(result.packageJson).toBe('updated'); + expect(result.prNumber).toBe(10); + expect(result.changes).toHaveLength(1); + expect(result.changes[0].field).toBe('devDependencies'); + }); + + test('should sync scripts when requested', async () => { + const sourcePackageJson = { + name: 'source-package', + devDependencies: { + eslint: '^9.0.0' + }, + scripts: { + test: 'jest', + lint: 'eslint .', + build: 'tsc' + } + }; + + const targetPackageJson = { + name: 'target-package', + devDependencies: { + eslint: '^9.0.0' + }, + scripts: { + test: 'jest', + old: 'old-script' + } + }; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(sourcePackageJson)); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'package-sha-456', + content: Buffer.from(JSON.stringify(targetPackageJson)).toString('base64') + } + }); + + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + 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: 11, + html_url: 'https://github.com/owner/repo/pull/11' + } + }); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + false, // syncDevDependencies + true, // syncScripts + 'chore: update package.json', + false + ); + + expect(result.success).toBe(true); + expect(result.packageJson).toBe('updated'); + expect(result.prNumber).toBe(11); + expect(result.changes).toHaveLength(1); + expect(result.changes[0].field).toBe('scripts'); + }); + + test('should sync both devDependencies and scripts when requested', async () => { + const sourcePackageJson = { + name: 'source-package', + devDependencies: { + eslint: '^9.0.0', + prettier: '^3.0.0' + }, + scripts: { + test: 'jest', + lint: 'eslint .' + } + }; + + const targetPackageJson = { + name: 'target-package', + devDependencies: { + eslint: '^8.0.0' + }, + scripts: { + test: 'mocha' + } + }; + + const packageLockJson = { + name: 'target-package', + lockfileVersion: 3, + packages: {} + }; + + // Mock file system operations for npm install + mockFs.readFileSync + .mockReturnValueOnce(JSON.stringify(sourcePackageJson)) // Reading source package.json + .mockReturnValueOnce(JSON.stringify(packageLockJson)); // Reading generated package-lock.json + + mockFs.existsSync.mockReturnValue(true); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent + .mockResolvedValueOnce({ + // First call: get package.json + data: { + sha: 'package-sha-789', + content: Buffer.from(JSON.stringify(targetPackageJson)).toString('base64') + } + }) + .mockResolvedValueOnce({ + // Second call: get package-lock.json + data: { + sha: 'package-lock-sha-789', + content: Buffer.from(JSON.stringify(packageLockJson)).toString('base64') + } + }); + + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + 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: 12, + html_url: 'https://github.com/owner/repo/pull/12' + } + }); + + mockChildProcess.execSync.mockReturnValue(undefined); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + true, // syncDevDependencies + true, // syncScripts + 'chore: update package.json', + false + ); + + expect(result.success).toBe(true); + expect(result.packageJson).toBe('updated'); + expect(result.prNumber).toBe(12); + expect(result.changes).toHaveLength(2); + expect(result.changes[0].field).toBe('devDependencies'); + expect(result.changes[1].field).toBe('scripts'); + }); + + test('should not create PR when content is unchanged', async () => { + const packageJson = { + name: 'package', + devDependencies: { + eslint: '^9.0.0' + }, + scripts: { + test: 'jest' + } + }; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'package-sha-999', + content: Buffer.from(JSON.stringify(packageJson)).toString('base64') + } + }); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + true, + true, + 'chore: update package.json', + false + ); + + expect(result.success).toBe(true); + expect(result.packageJson).toBe('unchanged'); + expect(result.message).toContain('already up to date'); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should handle dry-run mode', async () => { + const sourcePackageJson = { + name: 'source-package', + devDependencies: { + eslint: '^9.0.0' + } + }; + + const targetPackageJson = { + name: 'target-package', + devDependencies: { + eslint: '^8.0.0' + } + }; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(sourcePackageJson)); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'package-sha-dry', + content: Buffer.from(JSON.stringify(targetPackageJson)).toString('base64') + } + }); + + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + true, + false, + 'chore: update package.json', + true // dry-run + ); + + expect(result.success).toBe(true); + expect(result.packageJson).toBe('would-update'); + expect(result.dryRun).toBe(true); + expect(result.changes).toHaveLength(1); + expect(mockOctokit.rest.git.createRef).not.toHaveBeenCalled(); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should handle missing package.json in target repo', async () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ name: 'test', devDependencies: {} })); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockRejectedValue({ + status: 404 + }); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + true, + false, + 'chore: update package.json', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('does not exist'); + }); + + test('should handle invalid repository format', async () => { + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'invalid-repo', + './package.json', + true, + false, + 'chore: update package.json', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid repository format'); + }); + + test('should handle when neither syncDevDependencies nor syncScripts is enabled', async () => { + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + false, // syncDevDependencies + false, // syncScripts + 'chore: update package.json', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('At least one of syncDevDependencies or syncScripts must be enabled'); + }); + + test('should return pr-exists when open PR already exists', async () => { + const sourcePackageJson = { + name: 'source-package', + devDependencies: { + eslint: '^9.0.0' + } + }; + + const targetPackageJson = { + name: 'target-package', + devDependencies: { + eslint: '^8.0.0' + } + }; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(sourcePackageJson)); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'package-sha-pr', + content: Buffer.from(JSON.stringify(targetPackageJson)).toString('base64') + } + }); + + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [ + { + number: 99, + html_url: 'https://github.com/owner/repo/pull/99' + } + ] + }); + + const { syncPackageJson } = await import('../src/index.js'); + + const result = await syncPackageJson( + mockOctokit, + 'owner/repo', + './package.json', + true, + false, + 'chore: update package.json', + false + ); + + expect(result.success).toBe(true); + expect(result.packageJson).toBe('pr-exists'); + expect(result.prNumber).toBe(99); + expect(mockOctokit.rest.git.createRef).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + }); + }); + describe('syncDependabotYml', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/action.yml b/action.yml index 5dbd4c0..5698114 100644 --- a/action.yml +++ b/action.yml @@ -53,6 +53,21 @@ inputs: description: 'Title for pull requests when updating dependabot.yml' required: false default: 'chore: update dependabot.yml' + package-json-file: + description: 'Path to a package.json file to use as source for syncing devDependencies and/or scripts' + required: false + sync-dev-dependencies: + description: 'Sync devDependencies from package-json-file to target repositories' + required: false + default: 'false' + sync-scripts: + description: 'Sync npm scripts from package-json-file to target repositories' + required: false + default: 'false' + package-json-pr-title: + description: 'Title for pull requests when updating package.json' + required: false + default: 'chore: update package.json' dry-run: description: 'Preview changes without applying them (logs what would be changed)' required: false 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..5eca749 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,10 @@ * export INPUT_ALLOW_UPDATE_BRANCH="true" * export INPUT_DEPENDABOT_YML="./path/to/dependabot.yml" * export INPUT_DEPENDABOT_PR_TITLE="chore: update dependabot.yml" + * export INPUT_PACKAGE_JSON_FILE="./path/to/package.json" + * export INPUT_SYNC_DEV_DEPENDENCIES="true" + * export INPUT_SYNC_SCRIPTS="true" + * export INPUT_PACKAGE_JSON_PR_TITLE="chore: update package.json" * * 2. Run locally: * node src/index.js @@ -682,6 +686,387 @@ export async function syncDependabotYml(octokit, repo, dependabotYmlPath, prTitl } } +/** + * Sync package.json devDependencies and/or scripts to target repository + * @param {Octokit} octokit - Octokit instance + * @param {string} repo - Repository in "owner/repo" format + * @param {string} packageJsonPath - Path to local package.json file + * @param {boolean} syncDevDependencies - Whether to sync devDependencies + * @param {boolean} syncScripts - Whether to sync scripts + * @param {string} prTitle - Title for the pull request + * @param {boolean} dryRun - Preview mode without making actual changes + * @returns {Promise} Result object + */ +export async function syncPackageJson( + octokit, + repo, + packageJsonPath, + syncDevDependencies, + syncScripts, + prTitle, + dryRun +) { + const [owner, repoName] = repo.split('/'); + const targetPath = 'package.json'; + + if (!owner || !repoName) { + return { + repository: repo, + success: false, + error: 'Invalid repository format. Expected "owner/repo"', + dryRun + }; + } + + if (!syncDevDependencies && !syncScripts) { + return { + repository: repo, + success: false, + error: 'At least one of syncDevDependencies or syncScripts must be enabled', + dryRun + }; + } + + try { + // Read the source package.json file + let sourcePackageJson; + try { + const sourceContent = fs.readFileSync(packageJsonPath, 'utf8'); + sourcePackageJson = JSON.parse(sourceContent); + } catch (error) { + return { + repository: repo, + success: false, + error: `Failed to read or parse package.json file at ${packageJsonPath}: ${error.message}`, + dryRun + }; + } + + // Get default branch + const { data: repoData } = await octokit.rest.repos.get({ + owner, + repo: repoName + }); + const defaultBranch = repoData.default_branch; + + // Check if package.json exists in the target repo + let existingPackageJson = null; + let existingSha = null; + + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo: repoName, + path: targetPath, + ref: defaultBranch + }); + existingSha = data.sha; + const existingContent = Buffer.from(data.content, 'base64').toString('utf8'); + existingPackageJson = JSON.parse(existingContent); + } catch (error) { + if (error.status === 404) { + return { + repository: repo, + success: false, + error: `${targetPath} does not exist in ${repo}. This action only updates existing package.json files.`, + dryRun + }; + } else { + throw error; + } + } + + // Create updated package.json by merging selected fields + const updatedPackageJson = { ...existingPackageJson }; + const changes = []; + + if (syncDevDependencies && sourcePackageJson.devDependencies) { + const oldDevDeps = existingPackageJson.devDependencies || {}; + const newDevDeps = sourcePackageJson.devDependencies; + + // Check if there are any changes + const devDepsChanged = + JSON.stringify(oldDevDeps, Object.keys(oldDevDeps).sort()) !== + JSON.stringify(newDevDeps, Object.keys(newDevDeps).sort()); + + if (devDepsChanged) { + updatedPackageJson.devDependencies = newDevDeps; + changes.push({ + field: 'devDependencies', + from: oldDevDeps, + to: newDevDeps + }); + } + } + + if (syncScripts && sourcePackageJson.scripts) { + const oldScripts = existingPackageJson.scripts || {}; + const newScripts = sourcePackageJson.scripts; + + // Check if there are any changes + const scriptsChanged = + JSON.stringify(oldScripts, Object.keys(oldScripts).sort()) !== + JSON.stringify(newScripts, Object.keys(newScripts).sort()); + + if (scriptsChanged) { + updatedPackageJson.scripts = newScripts; + changes.push({ + field: 'scripts', + from: oldScripts, + to: newScripts + }); + } + } + + // If no changes needed + if (changes.length === 0) { + return { + repository: repo, + success: true, + packageJson: 'unchanged', + message: `${targetPath} is already up to date`, + dryRun + }; + } + + // Check if there's already an open PR for this update + const branchName = 'package-json-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, + packageJson: 'pr-exists', + message: `Open PR #${existingPR.number} already exists for ${targetPath}`, + prNumber: existingPR.number, + prUrl: existingPR.html_url, + changes, + dryRun + }; + } + + if (dryRun) { + return { + repository: repo, + success: true, + packageJson: 'would-update', + message: `Would update ${targetPath} via PR`, + changes, + 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}`); + } + + // Prepare the updated package.json content with proper formatting + const updatedContent = `${JSON.stringify(updatedPackageJson, null, 2)}\n`; + + // Step 1: Update package.json + const commitMessage = `chore: update ${targetPath}`; + await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo: repoName, + path: targetPath, + message: commitMessage, + content: Buffer.from(updatedContent).toString('base64'), + branch: branchName, + sha: existingSha + }); + + core.info(` ✍️ Committed changes to ${targetPath}`); + + // Step 2: Get package-lock.json if it exists + let packageLockSha = null; + let packageLockExists = false; + const packageLockPath = 'package-lock.json'; + + try { + const { data: packageLockData } = await octokit.rest.repos.getContent({ + owner, + repo: repoName, + path: packageLockPath, + ref: branchName + }); + packageLockSha = packageLockData.sha; + packageLockExists = true; + } catch (error) { + if (error.status === 404) { + core.info(` 📄 ${packageLockPath} does not exist, skipping lock file update`); + } else { + core.warning(` ⚠️ Could not check for ${packageLockPath}: ${error.message}`); + } + } + + // Step 3: If package-lock.json exists, generate an updated version + if (packageLockExists && syncDevDependencies) { + core.info(` 🔄 Generating updated ${packageLockPath}...`); + + // We need to run npm install to generate the lock file + // Since we can't run npm install remotely, we'll use a workaround: + // Clone the updated package.json, run npm install locally, and push the result + + const { execSync } = await import('child_process'); + const path = await import('path'); + const os = await import('os'); + + // Create a temporary directory + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-install-')); + + try { + // Write the updated package.json to temp directory + const tmpPackageJsonPath = path.join(tmpDir, 'package.json'); + fs.writeFileSync(tmpPackageJsonPath, updatedContent); + + // Run npm install to generate package-lock.json + core.info(` 📦 Running npm install in temporary directory...`); + execSync('npm install --package-lock-only', { + cwd: tmpDir, + stdio: 'pipe' + }); + + // Read the generated package-lock.json + const tmpPackageLockPath = path.join(tmpDir, 'package-lock.json'); + if (fs.existsSync(tmpPackageLockPath)) { + const updatedPackageLock = fs.readFileSync(tmpPackageLockPath, 'utf8'); + + // Commit the updated package-lock.json + await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo: repoName, + path: packageLockPath, + message: 'chore: update package-lock.json', + content: Buffer.from(updatedPackageLock).toString('base64'), + branch: branchName, + sha: packageLockSha + }); + + core.info(` ✍️ Committed changes to ${packageLockPath}`); + } else { + core.warning(` ⚠️ npm install did not generate ${packageLockPath}`); + } + } catch (error) { + core.warning(` ⚠️ Failed to update ${packageLockPath}: ${error.message}`); + } finally { + // Clean up temporary directory + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (error) { + core.warning(` ⚠️ Failed to clean up temporary directory: ${error.message}`); + } + } + } + + // Prepare PR body content + let prBody = `This PR updates \`package.json\``; + if (syncDevDependencies && syncScripts) { + prBody += ` to sync devDependencies and scripts.\n\n`; + } else if (syncDevDependencies) { + prBody += ` to sync devDependencies.\n\n`; + } else if (syncScripts) { + prBody += ` to sync scripts.\n\n`; + } + + prBody += '**Changes:**\n'; + for (const change of changes) { + prBody += `- Updated ${change.field}\n`; + } + + if (packageLockExists && syncDevDependencies) { + prBody += '- Updated package-lock.json\n'; + } + + // Create PR + const { data: pr } = await octokit.rest.pulls.create({ + owner, + repo: repoName, + title: prTitle, + head: branchName, + base: defaultBranch, + body: prBody + }); + const prNumber = pr.number; + core.info(` 📬 Created PR #${prNumber}: ${pr.html_url}`); + + return { + repository: repo, + success: true, + packageJson: 'updated', + prNumber, + prUrl: pr.html_url, + message: `Updated ${targetPath} via PR #${prNumber}`, + changes, + dryRun + }; + } catch (error) { + return { + repository: repo, + success: false, + error: `Failed to sync package.json: ${error.message}`, + dryRun + }; + } +} + /** * Check if a repository result has any changes * @param {Object} result - Repository update result object @@ -695,7 +1080,11 @@ function hasRepositoryChanges(result) { (result.dependabotSync && result.dependabotSync.success && result.dependabotSync.dependabotYml && - result.dependabotSync.dependabotYml !== 'unchanged') + result.dependabotSync.dependabotYml !== 'unchanged') || + (result.packageJsonSync && + result.packageJsonSync.success && + result.packageJsonSync.packageJson && + result.packageJsonSync.packageJson !== 'unchanged') ); } @@ -735,7 +1124,13 @@ 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 package.json sync settings + const packageJsonFile = getInput('package-json-file'); + const syncDevDependencies = getBooleanInput('sync-dev-dependencies'); + const syncScripts = getBooleanInput('sync-scripts'); + const packageJsonPrTitle = getInput('package-json-pr-title') || 'chore: update package.json'; core.info('Starting Bulk GitHub Repository Settings Action...'); @@ -749,10 +1144,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 || + (packageJsonFile && (syncDevDependencies || syncScripts)); 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 package-json-file with sync-dev-dependencies/sync-scripts must be specified)' ); } @@ -776,6 +1175,15 @@ export async function run() { if (dependabotYml) { core.info(`Dependabot.yml will be synced from: ${dependabotYml}`); } + if (packageJsonFile && (syncDevDependencies || syncScripts)) { + core.info(`Package.json will be synced from: ${packageJsonFile}`); + if (syncDevDependencies) { + core.info(' - devDependencies will be synced'); + } + if (syncScripts) { + core.info(' - scripts will be synced'); + } + } // Update repositories const results = []; @@ -851,7 +1259,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 +1280,67 @@ export async function run() { } } + // Handle repo-specific package.json settings + let repoPackageJsonFile = packageJsonFile; + let repoSyncDevDependencies = syncDevDependencies; + let repoSyncScripts = syncScripts; + if (repoConfig['package-json-file'] !== undefined) { + repoPackageJsonFile = repoConfig['package-json-file']; + } + if (repoConfig['sync-dev-dependencies'] !== undefined) { + repoSyncDevDependencies = repoConfig['sync-dev-dependencies']; + } + if (repoConfig['sync-scripts'] !== undefined) { + repoSyncScripts = repoConfig['sync-scripts']; + } + + // Sync package.json if specified + if (repoPackageJsonFile && (repoSyncDevDependencies || repoSyncScripts)) { + core.info(` 📦 Syncing package.json...`); + const packageJsonResult = await syncPackageJson( + octokit, + repo, + repoPackageJsonFile, + repoSyncDevDependencies, + repoSyncScripts, + packageJsonPrTitle, + dryRun + ); + + // Add package.json result to the main result + result.packageJsonSync = packageJsonResult; + + if (packageJsonResult.success) { + if (packageJsonResult.packageJson === 'unchanged') { + core.info(` 📦 ${packageJsonResult.message}`); + } else if (packageJsonResult.packageJson === 'pr-exists') { + core.info(` 📦 ${packageJsonResult.message}`); + if (packageJsonResult.prUrl) { + core.info(` 🔗 PR URL: ${packageJsonResult.prUrl}`); + } + } else if (dryRun) { + core.info(` 📦 ${packageJsonResult.message}`); + if (packageJsonResult.changes) { + for (const change of packageJsonResult.changes) { + core.info(` - Would update ${change.field}`); + } + } + } else { + core.info(` 📦 ${packageJsonResult.message}`); + if (packageJsonResult.prUrl) { + core.info(` 🔗 PR URL: ${packageJsonResult.prUrl}`); + } + if (packageJsonResult.changes) { + for (const change of packageJsonResult.changes) { + core.info(` - Updated ${change.field}`); + } + } + } + } else { + core.warning(` ⚠️ ${packageJsonResult.error}`); + } + } + if (result.success) { successCount++; if (dryRun) { From 2fed9b80d522846b4c7cc3b1b33ab536473a28f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:46:33 +0000 Subject: [PATCH 4/6] fix: address security concerns from code review Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- src/index.js | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/index.js b/src/index.js index 5eca749..6a2985d 100644 --- a/src/index.js +++ b/src/index.js @@ -780,14 +780,23 @@ export async function syncPackageJson( const updatedPackageJson = { ...existingPackageJson }; const changes = []; + // Helper function for comparing objects + const objectsAreEqual = (obj1, obj2) => { + const keys1 = Object.keys(obj1).sort(); + const keys2 = Object.keys(obj2).sort(); + + if (keys1.length !== keys2.length) return false; + if (keys1.join(',') !== keys2.join(',')) return false; + + return keys1.every(key => obj1[key] === obj2[key]); + }; + if (syncDevDependencies && sourcePackageJson.devDependencies) { const oldDevDeps = existingPackageJson.devDependencies || {}; const newDevDeps = sourcePackageJson.devDependencies; // Check if there are any changes - const devDepsChanged = - JSON.stringify(oldDevDeps, Object.keys(oldDevDeps).sort()) !== - JSON.stringify(newDevDeps, Object.keys(newDevDeps).sort()); + const devDepsChanged = !objectsAreEqual(oldDevDeps, newDevDeps); if (devDepsChanged) { updatedPackageJson.devDependencies = newDevDeps; @@ -804,9 +813,7 @@ export async function syncPackageJson( const newScripts = sourcePackageJson.scripts; // Check if there are any changes - const scriptsChanged = - JSON.stringify(oldScripts, Object.keys(oldScripts).sort()) !== - JSON.stringify(newScripts, Object.keys(newScripts).sort()); + const scriptsChanged = !objectsAreEqual(oldScripts, newScripts); if (scriptsChanged) { updatedPackageJson.scripts = newScripts; @@ -967,10 +974,9 @@ export async function syncPackageJson( const { execSync } = await import('child_process'); const path = await import('path'); - const os = await import('os'); - // Create a temporary directory - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-install-')); + // Create a secure temporary directory + const tmpDir = fs.mkdtempSync(path.join('/tmp', 'npm-install-')); try { // Write the updated package.json to temp directory @@ -979,10 +985,19 @@ export async function syncPackageJson( // Run npm install to generate package-lock.json core.info(` 📦 Running npm install in temporary directory...`); - execSync('npm install --package-lock-only', { - cwd: tmpDir, - stdio: 'pipe' - }); + try { + execSync('npm install --package-lock-only', { + cwd: tmpDir, + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8' + }); + } catch (execError) { + core.warning(` ⚠️ npm install failed: ${execError.message}`); + if (execError.stderr) { + core.warning(` npm stderr: ${execError.stderr}`); + } + throw execError; + } // Read the generated package-lock.json const tmpPackageLockPath = path.join(tmpDir, 'package-lock.json'); From ffb5ac788b52fbc601276109a69d823cea4c6bc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:48:51 +0000 Subject: [PATCH 5/6] fix: improve object comparison and cross-platform compatibility Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- src/index.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 6a2985d..172d726 100644 --- a/src/index.js +++ b/src/index.js @@ -780,15 +780,31 @@ export async function syncPackageJson( const updatedPackageJson = { ...existingPackageJson }; const changes = []; - // Helper function for comparing objects + // Helper function for deep comparing objects const objectsAreEqual = (obj1, obj2) => { + // Handle null/undefined + if (!obj1 || !obj2) return obj1 === obj2; + if (obj1 === obj2) return true; + const keys1 = Object.keys(obj1).sort(); const keys2 = Object.keys(obj2).sort(); if (keys1.length !== keys2.length) return false; if (keys1.join(',') !== keys2.join(',')) return false; - return keys1.every(key => obj1[key] === obj2[key]); + // Deep comparison for nested objects + return keys1.every(key => { + const val1 = obj1[key]; + const val2 = obj2[key]; + + // If both values are objects, recurse + if (typeof val1 === 'object' && val1 !== null && typeof val2 === 'object' && val2 !== null) { + return objectsAreEqual(val1, val2); + } + + // Otherwise, simple equality + return val1 === val2; + }); }; if (syncDevDependencies && sourcePackageJson.devDependencies) { @@ -974,9 +990,10 @@ export async function syncPackageJson( const { execSync } = await import('child_process'); const path = await import('path'); + const os = await import('os'); // Create a secure temporary directory - const tmpDir = fs.mkdtempSync(path.join('/tmp', 'npm-install-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-install-')); try { // Write the updated package.json to temp directory From d4b2d8def6a9d7aeaed5e39c526d67b96702904c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:49:23 +0000 Subject: [PATCH 6/6] docs: add example package.json template Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- example-package.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 example-package.json diff --git a/example-package.json b/example-package.json new file mode 100644 index 0000000..d4aab46 --- /dev/null +++ b/example-package.json @@ -0,0 +1,21 @@ +{ + "name": "example-package", + "version": "1.0.0", + "description": "Example package.json template for syncing devDependencies and scripts", + "scripts": { + "test": "jest", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "build": "tsc", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.0", + "jest": "^30.0.0", + "prettier": "^3.0.0", + "typescript": "^5.0.0" + } +}