From 819b6a16e2fe1b16431890f2ca9719165983ea30 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:16 +0000 Subject: [PATCH 1/4] Initial plan From 50eaceaa5a9a2883361ca6ceb84310916cf4af3c 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:58 +0000 Subject: [PATCH 2/4] Initial plan for copilot instructions sync feature 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 a0375eb8fb010a6fac4df5283717f9267141f7ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:41:06 +0000 Subject: [PATCH 3/4] feat: add ability to sync copilot instructions Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- README.md | 54 +++++-- __tests__/index.test.js | 305 +++++++++++++++++++++++++++++++++++++++- action.yml | 7 + package.json | 2 +- src/index.js | 299 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 650 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9105263..b9b1bb7 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 copilot-instructions.md files** 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,40 @@ repos: - PRs are created/updated using the GitHub API so commits are verified - Updates existing open PRs instead of creating duplicates +### Syncing Copilot Instructions + +Sync a `copilot-instructions.md` file to `.github/copilot-instructions.md` in target repositories via pull requests: + +```yml +- name: Sync Copilot Instructions + uses: joshjohanning/bulk-github-repo-settings-sync-action@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + repositories-file: 'repos.yml' + copilot-instructions-md: './.github/copilot-instructions.md' + copilot-instructions-pr-title: 'chore: update copilot-instructions.md' +``` + +Or with repo-specific overrides in `repos.yml`: + +```yaml +repos: + - repo: owner/repo1 + copilot-instructions-md: './config/copilot/javascript-instructions.md' + - repo: owner/repo2 + copilot-instructions-md: './config/copilot/python-instructions.md' + - repo: owner/repo3 + copilot-instructions-md: './.github/copilot-instructions.md' # use the same config that this repo is using +``` + +**Behavior:** + +- If `.github/copilot-instructions.md` doesn't exist, it creates it and opens a PR +- If it exists but differs, it updates it via PR +- 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 + ### Organization-wide Updates ```yml @@ -158,10 +193,12 @@ Output shows what would change: | `delete-branch-on-merge` | Automatically delete head branches after pull requests are merged | No | - | | `allow-update-branch` | Always suggest updating pull request branches | No | - | | `enable-default-code-scanning` | Enable default code scanning setup | No | - | -| `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` | -| `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | +| `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` | +| `copilot-instructions-md` | Path to a copilot-instructions.md file to sync to `.github/copilot-instructions.md` in target repositories | No | - | +| `copilot-instructions-pr-title`| Title for pull requests when updating copilot-instructions.md | No | `chore: update copilot-instructions.md` | +| `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | \* Either `repositories` or `repositories-file` must be provided @@ -238,6 +275,7 @@ repos: - repo: owner/repo3 enable-default-code-scanning: false dependabot-yml: './github/dependabot-configs/custom-dependabot.yml' + copilot-instructions-md: './github/copilot-instructions-custom.md' ``` **Priority:** Repository-specific settings override global defaults from action inputs. @@ -246,8 +284,8 @@ repos: - Settings not specified will remain unchanged - 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 +- Dependabot.yml and Copilot-instructions.md syncing creates pull requests for review before merging +- File syncing 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..8d15eaa 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -85,7 +85,8 @@ const { default: run, parseRepositories, updateRepositorySettings, - syncDependabotYml + syncDependabotYml, + syncCopilotInstructions } = await import('../src/index.js'); describe('Bulk GitHub Repository Settings Action', () => { @@ -126,6 +127,8 @@ describe('Bulk GitHub Repository Settings Action', () => { topics: '', 'dependabot-yml': '', 'dependabot-pr-title': '', + 'copilot-instructions-md': '', + 'copilot-instructions-pr-title': '', 'dry-run': '' }; return inputs[name] || ''; @@ -627,7 +630,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 copilot-instructions-md must be specified)' ); }); @@ -1074,4 +1077,302 @@ describe('Bulk GitHub Repository Settings Action', () => { expect(result.error).toContain('Failed to sync dependabot.yml'); }); }); + + describe('syncCopilotInstructions', () => { + 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 copilot-instructions.md when it does not exist', async () => { + const testContent = '# GitHub Copilot Instructions\n\nPlease follow our coding standards.'; + + mockFs.readFileSync.mockReturnValue(testContent); + + 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 syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './copilot-instructions.md', + 'chore: add copilot-instructions.md', + false + ); + + expect(result.success).toBe(true); + expect(result.copilotInstructions).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: '.github/copilot-instructions.md', + branch: 'copilot-instructions-md-sync' + }) + ); + expect(mockOctokit.rest.pulls.create).toHaveBeenCalled(); + }); + + test('should update copilot-instructions.md when content differs', async () => { + const newContent = '# GitHub Copilot Instructions\n\nUpdated coding standards.'; + const oldContent = '# GitHub Copilot Instructions\n\nOld coding standards.'; + + 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 syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './copilot-instructions.md', + 'chore: update copilot-instructions.md', + false + ); + + expect(result.success).toBe(true); + expect(result.copilotInstructions).toBe('updated'); + expect(result.prNumber).toBe(43); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith( + expect.objectContaining({ + sha: 'file-sha-456' + }) + ); + }); + + test('should not create PR when content is unchanged', async () => { + const content = '# GitHub Copilot Instructions\n\nCoding standards.'; + + 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 syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './copilot-instructions.md', + 'chore: update copilot-instructions.md', + false + ); + + expect(result.success).toBe(true); + expect(result.copilotInstructions).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 not create duplicate PR when one already exists', async () => { + const newContent = '# GitHub Copilot Instructions\n\nUpdated standards.'; + const oldContent = '# GitHub Copilot Instructions\n\nOld standards.'; + + mockFs.readFileSync.mockReturnValue(newContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockResolvedValue({ + data: { + sha: 'file-sha-999', + content: Buffer.from(oldContent).toString('base64') + } + }); + + // Existing PR found + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [ + { + number: 50, + html_url: 'https://github.com/owner/repo/pull/50' + } + ] + }); + + const result = await syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './copilot-instructions.md', + 'chore: update copilot-instructions.md', + false + ); + + expect(result.success).toBe(true); + expect(result.copilotInstructions).toBe('pr-exists'); + expect(result.prNumber).toBe(50); + expect(result.prUrl).toBe('https://github.com/owner/repo/pull/50'); + expect(mockOctokit.rest.git.createRef).not.toHaveBeenCalled(); + expect(mockOctokit.rest.git.updateRef).not.toHaveBeenCalled(); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled(); + }); + + test('should handle dry-run mode', async () => { + const newContent = '# GitHub Copilot Instructions\n\nNew standards.'; + + mockFs.readFileSync.mockReturnValue(newContent); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'main' + } + }); + + mockOctokit.rest.repos.getContent.mockRejectedValue({ + status: 404 + }); + + // No existing PR + mockOctokit.rest.pulls.list.mockResolvedValue({ + data: [] + }); + + const result = await syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './copilot-instructions.md', + 'chore: add copilot-instructions.md', + true // dry-run + ); + + expect(result.success).toBe(true); + expect(result.copilotInstructions).toBe('would-create'); + expect(result.dryRun).toBe(true); + expect(mockOctokit.rest.git.createRef).not.toHaveBeenCalled(); + expect(mockOctokit.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.create).not.toHaveBeenCalled(); + }); + + test('should handle invalid repository format', async () => { + const result = await syncCopilotInstructions( + mockOctokit, + 'invalid-repo-format', + './copilot-instructions.md', + 'chore: update copilot-instructions.md', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid repository format'); + }); + + test('should handle missing copilot-instructions.md file', async () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + const result = await syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './nonexistent.md', + 'chore: update copilot-instructions.md', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to read copilot-instructions.md file'); + }); + + test('should handle API errors', async () => { + mockFs.readFileSync.mockReturnValue('# Copilot Instructions'); + + mockOctokit.rest.repos.get.mockRejectedValue(new Error('API rate limit exceeded')); + + const result = await syncCopilotInstructions( + mockOctokit, + 'owner/repo', + './copilot-instructions.md', + 'chore: update copilot-instructions.md', + false + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to sync copilot-instructions.md'); + }); + }); }); diff --git a/action.yml b/action.yml index 5dbd4c0..9c3c905 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' + copilot-instructions-md: + description: 'Path to a copilot-instructions.md file to sync to .github/copilot-instructions.md in target repositories' + required: false + copilot-instructions-pr-title: + description: 'Title for pull requests when updating copilot-instructions.md' + required: false + default: 'chore: update copilot-instructions.md' 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..4d5c76a 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,8 @@ * 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_COPILOT_INSTRUCTIONS_MD="./path/to/copilot-instructions.md" + * export INPUT_COPILOT_INSTRUCTIONS_PR_TITLE="chore: update copilot-instructions.md" * * 2. Run locally: * node src/index.js @@ -682,10 +684,243 @@ export async function syncDependabotYml(octokit, repo, dependabotYmlPath, prTitl } } +/** + * Sync copilot-instructions.md file to target repository + * @param {Octokit} octokit - Octokit instance + * @param {string} repo - Repository in "owner/repo" format + * @param {string} copilotInstructionsPath - Path to local copilot-instructions.md 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 syncCopilotInstructions(octokit, repo, copilotInstructionsPath, prTitle, dryRun) { + const [owner, repoName] = repo.split('/'); + const targetPath = '.github/copilot-instructions.md'; + + if (!owner || !repoName) { + return { + repository: repo, + success: false, + error: 'Invalid repository format. Expected "owner/repo"', + dryRun + }; + } + + try { + // Read the source copilot-instructions.md file + let sourceContent; + try { + sourceContent = fs.readFileSync(copilotInstructionsPath, 'utf8'); + } catch (error) { + return { + repository: repo, + success: false, + error: `Failed to read copilot-instructions.md file at ${copilotInstructionsPath}: ${error.message}`, + dryRun + }; + } + + // Get default branch + const { data: repoData } = await octokit.rest.repos.get({ + owner, + repo: repoName + }); + const defaultBranch = repoData.default_branch; + + // Check if copilot-instructions.md exists in the target repo + let existingSha = null; + let existingContent = null; + + 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'); + } 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; + } + } + + // Compare content + const needsUpdate = !existingContent || existingContent.trim() !== sourceContent.trim(); + + if (!needsUpdate) { + return { + repository: repo, + success: true, + copilotInstructions: 'unchanged', + message: `${targetPath} is already up to date`, + dryRun + }; + } + + // Check if there's already an open PR for this update + const branchName = 'copilot-instructions-md-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, + copilotInstructions: '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, + copilotInstructions: 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(sourceContent).toString('base64'), + branch: branchName, + sha: existingSha || undefined + }); + + core.info(` ✍️ Committed changes to ${targetPath}`); + + // Prepare PR body content + const prBody = existingContent + ? `This PR updates \`.github/copilot-instructions.md\` to the latest version.\n\n**Changes:**\n- Updated Copilot instructions` + : `This PR adds \`.github/copilot-instructions.md\` to configure GitHub Copilot.\n\n**Changes:**\n- Added Copilot instructions`; + + // 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, + copilotInstructions: 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 copilot-instructions.md: ${error.message}`, + dryRun + }; + } +} + /** * Check if a repository result has any changes * @param {Object} result - Repository update result object - * @returns {boolean} True if there are any changes (settings, topics, code scanning, or dependabot) + * @returns {boolean} True if there are any changes (settings, topics, code scanning, dependabot, or copilot instructions) */ function hasRepositoryChanges(result) { return ( @@ -695,7 +930,11 @@ function hasRepositoryChanges(result) { (result.dependabotSync && result.dependabotSync.success && result.dependabotSync.dependabotYml && - result.dependabotSync.dependabotYml !== 'unchanged') + result.dependabotSync.dependabotYml !== 'unchanged') || + (result.copilotInstructionsSync && + result.copilotInstructionsSync.success && + result.copilotInstructionsSync.copilotInstructions && + result.copilotInstructionsSync.copilotInstructions !== 'unchanged') ); } @@ -735,7 +974,12 @@ 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 copilot-instructions.md settings + const copilotInstructionsMd = getInput('copilot-instructions-md'); + const copilotInstructionsPrTitle = + getInput('copilot-instructions-pr-title') || 'chore: update copilot-instructions.md'; core.info('Starting Bulk GitHub Repository Settings Action...'); @@ -749,10 +993,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 || + copilotInstructionsMd; 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 copilot-instructions-md must be specified)' ); } @@ -776,6 +1024,9 @@ export async function run() { if (dependabotYml) { core.info(`Dependabot.yml will be synced from: ${dependabotYml}`); } + if (copilotInstructionsMd) { + core.info(`Copilot-instructions.md will be synced from: ${copilotInstructionsMd}`); + } // Update repositories const results = []; @@ -838,6 +1089,12 @@ export async function run() { repoDependabotYml = repoConfig['dependabot-yml']; } + // Handle repo-specific copilot-instructions.md + let repoCopilotInstructionsMd = copilotInstructionsMd; + if (repoConfig['copilot-instructions-md'] !== undefined) { + repoCopilotInstructionsMd = repoConfig['copilot-instructions-md']; + } + const result = await updateRepositorySettings( octokit, repo, @@ -851,7 +1108,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 +1129,36 @@ export async function run() { } } + // Sync copilot-instructions.md if specified + if (repoCopilotInstructionsMd) { + core.info(` 🤖 Syncing copilot-instructions.md...`); + const copilotResult = await syncCopilotInstructions( + octokit, + repo, + repoCopilotInstructionsMd, + copilotInstructionsPrTitle, + dryRun + ); + + // Add copilot instructions result to the main result + result.copilotInstructionsSync = copilotResult; + + if (copilotResult.success) { + if (copilotResult.copilotInstructions === 'unchanged') { + core.info(` 🤖 ${copilotResult.message}`); + } else if (dryRun) { + core.info(` 🤖 ${copilotResult.message}`); + } else { + core.info(` 🤖 ${copilotResult.message}`); + if (copilotResult.prUrl) { + core.info(` 🔗 PR URL: ${copilotResult.prUrl}`); + } + } + } else { + core.warning(` ⚠️ ${copilotResult.error}`); + } + } + if (result.success) { successCount++; if (dryRun) { From 3192ea73f4305d9edc94e252b45a2a8d45f2750b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:42:52 +0000 Subject: [PATCH 4/4] chore: format README table alignment Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b9b1bb7..bebfba1 100644 --- a/README.md +++ b/README.md @@ -179,26 +179,26 @@ Output shows what would change: ## Action Inputs -| Input | Description | Required | Default | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------ | -| `github-token` | GitHub token for API access (requires `repo` scope or GitHub App with repository administration) | Yes | - | -| `github-api-url` | GitHub API URL (e.g., `https://api.github.com` for GitHub.com or `https://ghes.domain.com/api/v3` for GHES). Instance URL is auto-derived. | No | `${{ github.api_url }}` | -| `repositories` | Comma-separated list of repositories (`owner/repo`) or `"all"` for all org/user repos | No\* | - | -| `repositories-file` | Path to YAML file containing repository list | No\* | - | -| `owner` | Owner (user or organization) name - required when using `repositories: "all"` | No | - | -| `allow-squash-merge` | Allow squash merging pull requests | No | - | -| `allow-merge-commit` | Allow merge commits for pull requests | No | - | -| `allow-rebase-merge` | Allow rebase merging pull requests | No | - | -| `allow-auto-merge` | Allow auto-merge on pull requests | No | - | -| `delete-branch-on-merge` | Automatically delete head branches after pull requests are merged | No | - | -| `allow-update-branch` | Always suggest updating pull request branches | No | - | -| `enable-default-code-scanning` | Enable default code scanning setup | No | - | -| `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` | -| `copilot-instructions-md` | Path to a copilot-instructions.md file to sync to `.github/copilot-instructions.md` in target repositories | No | - | -| `copilot-instructions-pr-title`| Title for pull requests when updating copilot-instructions.md | No | `chore: update copilot-instructions.md` | -| `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | +| Input | Description | Required | Default | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------- | +| `github-token` | GitHub token for API access (requires `repo` scope or GitHub App with repository administration) | Yes | - | +| `github-api-url` | GitHub API URL (e.g., `https://api.github.com` for GitHub.com or `https://ghes.domain.com/api/v3` for GHES). Instance URL is auto-derived. | No | `${{ github.api_url }}` | +| `repositories` | Comma-separated list of repositories (`owner/repo`) or `"all"` for all org/user repos | No\* | - | +| `repositories-file` | Path to YAML file containing repository list | No\* | - | +| `owner` | Owner (user or organization) name - required when using `repositories: "all"` | No | - | +| `allow-squash-merge` | Allow squash merging pull requests | No | - | +| `allow-merge-commit` | Allow merge commits for pull requests | No | - | +| `allow-rebase-merge` | Allow rebase merging pull requests | No | - | +| `allow-auto-merge` | Allow auto-merge on pull requests | No | - | +| `delete-branch-on-merge` | Automatically delete head branches after pull requests are merged | No | - | +| `allow-update-branch` | Always suggest updating pull request branches | No | - | +| `enable-default-code-scanning` | Enable default code scanning setup | No | - | +| `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` | +| `copilot-instructions-md` | Path to a copilot-instructions.md file to sync to `.github/copilot-instructions.md` in target repositories | No | - | +| `copilot-instructions-pr-title` | Title for pull requests when updating copilot-instructions.md | No | `chore: update copilot-instructions.md` | +| `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` | \* Either `repositories` or `repositories-file` must be provided