feat: add app installation plugin for managing GitHub App repo access#1012
Open
decyjphr wants to merge 21 commits into
Open
feat: add app installation plugin for managing GitHub App repo access#1012decyjphr wants to merge 21 commits into
decyjphr wants to merge 21 commits into
Conversation
Add a new plugin system where the target is a GitHub App installation rather than a repository. This enables managing which repos each app has access to (repository_selection) through the same config hierarchy (org → suborg → repo) that safe-settings uses for repo-level settings. New files: - lib/plugins/appInstallations.js: Plugin with delta + full sync modes - lib/appOctokitClient.js: Enterprise Octokit client with auto-batching at 50 repos per API call - lib/repoSelector.js: Repo resolution utility (name, team, properties) Integration: - settings.js: syncAppInstallations as separate phase after updateOrg - index.js: installation/installation_target webhook handlers for drift detection, enrichContextWithEnterprise helper Supports disable_plugins and additive_plugins. Enterprise slug is extracted from webhook payload (no env var needed). Closes #1005 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
decyjphr
commented
Jun 25, 2026
robot.auth() without args returns a JWT app client, not an enterprise installation-authenticated client. Fix enrichContextWithEnterprise to: 1. Use JWT client to list all installations 2. Find the enterprise installation matching payload.enterprise.slug 3. Call robot.auth(enterpriseInstallation.id) to get the properly authenticated client for enterprise API calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Store the enterprise installation ID after the first successful lookup so subsequent calls to enrichContextWithEnterprise can skip the listInstallations API call and directly call robot.auth(cachedId). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
decyjphr
commented
Jun 25, 2026
- Call enrichContextWithEnterprise in syncSelectedSettings (index.js) - Call syncAppInstallations with changedSubOrgs/changedRepos in syncSelectedRepos - Add _buildAppChangesFromDelta helper to extract affected apps from changed suborg/repo configs and compute repository_selection per app - syncAppInstallations now handles three modes: pre-computed delta, config-based delta (from changed files), and full sync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Load previous version of changed suborg/repo configs using loadYamlFromRef - Compare old vs new app_installations sections to detect: - Apps removed from config → unselect all previously targeted repos - Targeting criteria changed → unselect repos no longer in scope - Selection takes precedence over unselection when both apply - Pass baseRef through syncAppInstallations → _buildAppChangesFromDelta Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Delta mode now skips apps configured as repository_selection: all at org level (org 'all' takes precedence — never add/remove repos via delta) - Fix repo config path used to load previous version from baseRef: use CONFIG_PATH/repos/<repo>.yml instead of bare repos/<repo>.yml (the bare path 404'd, so repo-level repository_unselection was never computed) - schema/settings.json: add app_installations top-level property and include it in additive_plugins and disable_plugins enums for editor validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ng, docs - Remove installation.repositories_added/removed handler: an app only receives those events for its own installation, so they cannot detect drift on managed apps. Drift is reconciled by the scheduled full sync. - Process repository_unselection before repository_selection (delta and full sync) so a repo removed by one config and added by another ends up present - Add test asserting removal-before-addition ordering - Document app_installations in README (prerequisites, hierarchy, examples, sync behavior, drift note, disable/additive support) and add it to the configurable-items and disable_plugins lists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In delta mode, when an app is present in both the previous and current suborg/repo config: - If suborg targeting is unchanged, skip the app entirely (no selection or unselection) — avoids re-adding all suborg repos on unrelated config edits - If targeting changed, emit only the diff (newly targeted repos to add, no-longer targeted repos to remove) instead of re-adding the full set - Repo-level: only select when the app is newly added to the repo config Add unit tests covering the skip, targeting-diff, and org-'all' precedence cases for _buildAppChangesFromDelta. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d/remove)
Rewrite appOctokitClient to the documented 2026-03-10 endpoints:
- org-scoped paths under /enterprises/{ent}/apps/organizations/{org}/...
- repository NAMES instead of IDs
- setRepositorySelection toggle for 'all'/'selected'
- PATCH /add and PATCH /remove, batched at 50
Update appInstallations plugin to pass org, drop ID resolution and
all-repo enumeration, use the toggle for 'all', and handle live
current_selection (all<->selected transitions) in full sync.
Thread current_selection through _computeFullAppDesiredState.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… function and simplify context enrichment
Document the design and decisions for the app_installations plugin: enterprise auth as prerequisite, separate sync phase, fixed repo-selection criteria, org-all precedence, names-based Enterprise API, delta/full sync, drift limitations, and future target abstraction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…po selection logic
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces an app_installations capability to safe-settings to manage GitHub App installation repository access (enterprise org installations API) as a separate sync phase, including delta-based updates from changed config files and full reconciliation during full sync runs. It also extends reporting to support “non-repo subjects” (apps) so NOP/check output remains readable and repo counts remain accurate.
Changes:
- Added new app-installation management plugin (
app_installations) plus enterprise Octokit wrapper and repo selection utility (name/team/custom properties/all). - Wired a new
syncAppInstallations()phase into the settings pipeline and updated NOP reporting to support app-as-subject output. - Added schema/docs/tests and smoke-test coverage for the new phase.
Show a summary per file
| File | Description |
|---|---|
index.js |
Adds enterprise context enrichment, startup verification, and installation_target handler; feeds enterprise client/slug into settings sync. |
lib/settings.js |
Adds syncAppInstallations() phase, delta computation, full desired-state computation, and reporting grouping by “subject”. |
lib/plugins/appInstallations.js |
Implements delta/full reconciliation of app installation repo access (including additive mode behavior). |
lib/appOctokitClient.js |
Wraps enterprise org installations API with batching (50) and convenience methods. |
lib/repoSelector.js |
Resolves repo names via fixed criteria (names/globs, teams, custom properties, all). |
lib/nopcommand.js |
Adds optional subject/subjectType to support app-centric reporting without breaking existing plugins. |
schema/settings.json |
Adds app_installations schema and extends additive/disable plugin enums to include app_installations. |
README.md |
Documents app_installations behavior, prerequisites, and config patterns. |
docs/adr/README.md |
Introduces ADR index. |
docs/adr/0001-app-installation-plugin.md |
Captures the design/decisions for the new app-installation feature. |
smoke-test.js |
Adds Phase 17 to exercise app installation management end-to-end against an enterprise. |
test/unit/lib/settings.test.js |
Updates additive plugin expectations and adds delta-build tests for app changes. |
test/unit/lib/plugins/appInstallations.test.js |
Adds unit tests for delta and full sync behaviors, additive mode, and ordering. |
test/unit/lib/appOctokitClient.test.js |
Adds unit tests for enterprise client behavior and batching. |
test/unit/lib/repoSelector.test.js |
Adds unit tests for repo resolution criteria and precedence. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 15/15 changed files
- Comments generated: 8
- Review effort level: Low
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Comment on lines
+1690
to
+1706
| for (const repo of changedRepos) { | ||
| const repoConfig = this.repoConfigs && | ||
| (this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`]) | ||
| const currentApps = (repoConfig && repoConfig.app_installations) || [] | ||
| const currentAppSlugs = new Set(currentApps.map(a => a.app_slug).filter(Boolean)) | ||
|
|
||
| // Load previous version of this repo config | ||
| let previousApps = [] | ||
| if (baseRef) { | ||
| const repoFilePath = path.posix.join(CONFIG_PATH, 'repos', `${repo.repo}.yml`) | ||
| try { | ||
| const previousData = await this.loadYamlFromRef(repoFilePath, baseRef) | ||
| previousApps = (previousData && previousData.app_installations) || [] | ||
| } catch (e) { | ||
| this.log.debug(`Could not load previous repo config for '${repo.repo}': ${e.message}`) | ||
| } | ||
| } |
…rations and report unknown apps in logs
Comment on lines
+1772
to
+1785
| // Convert Sets to arrays and remove repos that appear in both selection and unselection | ||
| // (selection wins — if a repo is being added by one config and removed by another, keep it) | ||
| const results = [] | ||
| for (const change of appChangeMap.values()) { | ||
| for (const repo of change.repository_selection) { | ||
| change.repository_unselection.delete(repo) | ||
| } | ||
| results.push({ | ||
| ...change, | ||
| repository_selection: [...change.repository_selection], | ||
| repository_unselection: [...change.repository_unselection] | ||
| }) | ||
| } | ||
| return results |
Comment on lines
+1619
to
+1622
| } catch (e) { | ||
| this.log.error(`Failed to list org installations for delta: ${e.message}`) | ||
| return [] | ||
| } |
Comment on lines
+1808
to
+1811
| } catch (e) { | ||
| this.log.error(`Failed to list org installations: ${e.message}`) | ||
| return desiredState | ||
| } |
Comment on lines
+1733
to
+1751
| // Process changed repo configs | ||
| for (const repo of changedRepos) { | ||
| const repoConfig = this.repoConfigs && | ||
| (this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`]) | ||
| const currentApps = (repoConfig && repoConfig.app_installations) || [] | ||
| const currentAppSlugs = new Set(currentApps.map(a => a.app_slug).filter(Boolean)) | ||
|
|
||
| // Load previous version of this repo config | ||
| let previousApps = [] | ||
| if (baseRef) { | ||
| const repoFilePath = path.posix.join(CONFIG_PATH, 'repos', `${repo.repo}.yml`) | ||
| try { | ||
| const previousData = await this.loadYamlFromRef(repoFilePath, baseRef) | ||
| previousApps = (previousData && previousData.app_installations) || [] | ||
| } catch (e) { | ||
| this.log.debug(`Could not load previous repo config for '${repo.repo}': ${e.message}`) | ||
| } | ||
| } | ||
| const previousAppSlugs = new Set(previousApps.map(a => a.app_slug).filter(Boolean)) |
Comment on lines
+1697
to
+1703
| it('skips apps configured as repository_selection: all at org level', async () => { | ||
| jest.spyOn(RepoSelector.prototype, 'resolve').mockResolvedValue(new Set(['repo-a'])) | ||
|
|
||
| settings.config = { | ||
| ...settings.config, | ||
| app_installations: [{ app_slug: 'my-app', repository_selection: 'all' }] | ||
| } |
Comment on lines
+1693
to
+1694
| expect(result[0].repository_selection.sort()).toEqual(['repo-c']) | ||
| expect(result[0].repository_unselection.sort()).toEqual(['repo-a']) |
Comment on lines
+175
to
+180
| async function findEnterpriseInstallation (enterpriseSlug) { | ||
| const installations = await listAllInstallations() | ||
| return installations.find( | ||
| i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterpriseSlug | ||
| ) || null | ||
| } |
Comment on lines
+201
to
+205
| const cachedId = cachedEnterpriseInstallationIds.get(enterpriseSlug) | ||
| if (cachedId) { | ||
| const appGithub = await robot.auth(cachedId) | ||
| return { appGithub, installationId: cachedId } | ||
| } |
Comment on lines
+628
to
+632
| // App installation target handler | ||
| // | ||
| // Note: We intentionally do NOT handle `installation.repositories_added` / | ||
| // `installation.repositories_removed`. A GitHub App only receives those | ||
| // events for its OWN installation, not for the managed apps (e.g. Copilot, |
…sed repo (loadConfigs(repo) loads a single repo override), so missing entries
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Adds a new plugin system to safe-settings where the target is a GitHub App installation rather than a repository. This enables managing which repos each app has access to (
repository_selection) through the same config hierarchy (org → suborg → repo).Closes #1005
Changes
New files
lib/plugins/appInstallations.jslib/appOctokitClient.jslib/repoSelector.jstest/unit/lib/plugins/appInstallations.test.jstest/unit/lib/appOctokitClient.test.jstest/unit/lib/repoSelector.test.jsModified files
lib/settings.jssyncAppInstallationsas separate phase, registered inPLUGINSandADDITIVE_PLUGINSindex.jsinstallation/installation_targetwebhook handlers for drift detection,enrichContextWithEnterprisehelpertest/unit/lib/settings.test.jsDesign
Delta-based processing (efficient at scale)
Key features
Testing
All 292 unit tests pass (20 suites). 35 new tests added across 3 test files.