-
Notifications
You must be signed in to change notification settings - Fork 211
feat: add app installation plugin for managing GitHub App repo access #1012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
decyjphr
wants to merge
26
commits into
yadhav/fix-recent-issues
Choose a base branch
from
decyjphr-app-installation-plugin
base: yadhav/fix-recent-issues
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
5894eae
feat: add app installation plugin for managing GitHub App repo access
decyjphr a51ab87
fix: use enterprise installation token for app installation API calls
decyjphr 8fee0dd
perf: cache enterprise installation ID to avoid repeated lookups
decyjphr d0be177
Wire syncAppInstallations into syncSelectedRepos for delta processing
decyjphr 24fb684
Compute repository_unselection by diffing previous vs current configs
decyjphr 4b2cec7
Fix delta bugs: org-all precedence + repo config path; update schema
decyjphr b731d54
Address app installation review: remove bad drift handler, fix orderi…
decyjphr 9a25bdc
Skip redundant app installation churn for unchanged delta
decyjphr eacef7f
Use correct Enterprise Org Installations API (names, toggle, PATCH ad…
decyjphr c55645e
Add verification for app-installations plugin functionality
decyjphr c43625a
Refactor enterprise installation handling: add getEnterpriseAppClient…
decyjphr 1364b09
Add ADR 0001: app installation management plugin
decyjphr 99cd47e
Enhance app installations plugin: add subject handling and improve re…
decyjphr 47a8d11
ADR updates
decyjphr 7832694
Refactor enterprise installation handling: update caching logic and i…
decyjphr 366522b
Add functions to list installations and find enterprise installation
decyjphr b4f00b2
Potential fix for pull request finding
decyjphr b6b87a0
Resolved CCR PR review
decyjphr d22a410
Removed repository_selection as a field in the settings.yml
decyjphr 31652a7
Enhance app_installations handling: add detection for layered configu…
decyjphr 1b63531
syncSelectedRepos() repoConfigs often contains only the *last* proces…
decyjphr 26b5010
Handle authentication errors by clearing cached enterprise installati…
decyjphr 60c4b7a
Map and Array mismatch will cause delta-based sync to skip real add/r…
decyjphr 19d911b
Fix error that makes the app_installations phase silently no-op (and …
decyjphr 97e2b81
protective to change to lowercase
decyjphr 4e8240c
cleaned up test
decyjphr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| const BATCH_SIZE = 50 | ||
|
|
||
| /** | ||
| * AppOctokitClient wraps an Octokit client authenticated as the GitHub App | ||
| * (JWT) and provides methods for managing app installation repository access | ||
| * via the Enterprise Organization Installations API. | ||
| * | ||
| * Prerequisites: | ||
| * - safe-settings must be installed on the enterprise with | ||
| * "Enterprise organization installations" permission. | ||
| * - The enterprise slug is obtained from the webhook event payload | ||
| * (payload.enterprise.slug). | ||
| * | ||
| * @param {object} options | ||
| * @param {object} options.github - Octokit client authenticated as the app (via robot.auth()) | ||
| * @param {string} options.enterpriseSlug - Enterprise slug from webhook payload | ||
| * @param {object} options.log - Logger instance | ||
| */ | ||
| class AppOctokitClient { | ||
| constructor ({ github, enterpriseSlug, log }) { | ||
| this.github = github | ||
| this.enterpriseSlug = enterpriseSlug | ||
| this.log = log | ||
| } | ||
|
|
||
| /** | ||
| * List all app installations in the enterprise for a given org. | ||
| * Returns array of installation objects with { id, app_slug, app_id, ... } | ||
| * | ||
| * @param {string} org - Organization login name | ||
| * @returns {Promise<Array>} List of installations | ||
| */ | ||
| async listOrgInstallations (org) { | ||
| try { | ||
| const options = this.github.request.endpoint.merge( | ||
| 'GET /enterprises/{enterprise}/apps/installations', | ||
| { | ||
| enterprise: this.enterpriseSlug, | ||
| headers: { 'X-GitHub-Api-Version': '2026-03-10' } | ||
| } | ||
| ) | ||
| const installations = await this.github.paginate(options) | ||
| // Filter to installations for the specified org | ||
| return installations.filter(i => | ||
| i.account && i.account.login === org | ||
| ) | ||
| } catch (e) { | ||
| if (e.status === 403 || e.status === 404) { | ||
| throw new Error( | ||
| `Cannot access enterprise installations API. Ensure safe-settings is installed on the enterprise '${this.enterpriseSlug}' with 'Enterprise organization installations' permission. Error: ${e.message}` | ||
| ) | ||
| } | ||
| throw e | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * List repositories accessible to an app installation. | ||
| * | ||
| * @param {number} installationId - The installation ID | ||
| * @returns {Promise<Array>} List of repository objects | ||
| */ | ||
| async listInstallationRepos (installationId) { | ||
| try { | ||
| const options = this.github.request.endpoint.merge( | ||
| 'GET /enterprises/{enterprise}/apps/installations/{installation_id}/repositories', | ||
| { | ||
| enterprise: this.enterpriseSlug, | ||
| installation_id: installationId, | ||
| headers: { 'X-GitHub-Api-Version': '2026-03-10' } | ||
| } | ||
| ) | ||
| return this.github.paginate(options) | ||
| } catch (e) { | ||
| this.log.error(`Error listing repos for installation ${installationId}: ${e.message}`) | ||
| throw e | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Grant repository access to an app installation. | ||
| * Automatically batches into chunks of 50 (API limit). | ||
| * | ||
| * @param {number} installationId - The installation ID | ||
| * @param {number[]} repositoryIds - Array of repository IDs to add | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async addReposToInstallation (installationId, repositoryIds) { | ||
| if (!repositoryIds || repositoryIds.length === 0) return | ||
|
|
||
| const batches = this._chunk(repositoryIds, BATCH_SIZE) | ||
| for (const batch of batches) { | ||
| this.log.debug(`Adding ${batch.length} repos to installation ${installationId}`) | ||
| await this.github.request( | ||
| 'POST /enterprises/{enterprise}/apps/installations/{installation_id}/repositories', | ||
| { | ||
| enterprise: this.enterpriseSlug, | ||
| installation_id: installationId, | ||
| repository_ids: batch, | ||
| headers: { 'X-GitHub-Api-Version': '2026-03-10' } | ||
| } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Remove repository access from an app installation. | ||
| * Automatically batches into chunks of 50 (API limit). | ||
| * | ||
| * @param {number} installationId - The installation ID | ||
| * @param {number[]} repositoryIds - Array of repository IDs to remove | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async removeReposFromInstallation (installationId, repositoryIds) { | ||
| if (!repositoryIds || repositoryIds.length === 0) return | ||
|
|
||
| const batches = this._chunk(repositoryIds, BATCH_SIZE) | ||
| for (const batch of batches) { | ||
| this.log.debug(`Removing ${batch.length} repos from installation ${installationId}`) | ||
| await this.github.request( | ||
| 'DELETE /enterprises/{enterprise}/apps/installations/{installation_id}/repositories', | ||
| { | ||
| enterprise: this.enterpriseSlug, | ||
| installation_id: installationId, | ||
| repository_ids: batch, | ||
| headers: { 'X-GitHub-Api-Version': '2026-03-10' } | ||
| } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Split an array into chunks of the given size. | ||
| * @private | ||
| */ | ||
| _chunk (array, size) { | ||
| const chunks = [] | ||
| for (let i = 0; i < array.length; i += size) { | ||
| chunks.push(array.slice(i, i + size)) | ||
| } | ||
| return chunks | ||
| } | ||
| } | ||
|
|
||
| module.exports = AppOctokitClient |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.