Skip to content

Commit 5894eae

Browse files
decyjphrCopilot
andcommitted
feat: add app installation plugin for managing GitHub App repo access
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>
1 parent 8c20bf2 commit 5894eae

9 files changed

Lines changed: 1330 additions & 6 deletions

File tree

index.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
2121
const config = Object.assign({}, deploymentConfig, runtimeConfig)
2222
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
2323

24+
// Enrich context with enterprise info for app installation management
25+
await enrichContextWithEnterprise(context)
26+
2427
// Load base branch config for NOP filtering (only show PR-introduced changes)
2528
let baseConfig = null
2629
if (nop && baseRef) {
@@ -142,6 +145,26 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
142145
}
143146
}
144147
}
148+
/**
149+
* Enriches the context with enterprise info for app installation management.
150+
* Extracts enterprise slug from the webhook payload and creates an
151+
* app-authenticated Octokit client for enterprise API calls.
152+
*
153+
* @param {object} context - Probot context
154+
*/
155+
async function enrichContextWithEnterprise (context) {
156+
const { payload } = context
157+
const enterprise = payload.enterprise || (payload.installation && payload.installation.enterprise)
158+
if (enterprise && enterprise.slug) {
159+
context.enterpriseSlug = enterprise.slug
160+
try {
161+
context.appGithub = await robot.auth()
162+
} catch (e) {
163+
robot.log.debug(`Could not create app-authenticated client for enterprise: ${e.message}`)
164+
}
165+
}
166+
}
167+
145168
/**
146169
* Loads the deployment config file from file system
147170
* Do this once when the app starts and then return the cached value
@@ -482,6 +505,55 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
482505
}
483506
})
484507

508+
// ────────────────────────────────────────────────────────────────────────
509+
// App installation drift detection handlers
510+
// ────────────────────────────────────────────────────────────────────────
511+
512+
const installation_change_events = [
513+
'installation.repositories_added',
514+
'installation.repositories_removed'
515+
]
516+
517+
robot.on(installation_change_events, async context => {
518+
const { payload } = context
519+
const { sender } = payload
520+
robot.log.debug('App installation repos changed by ', JSON.stringify(sender))
521+
if (sender.type === 'Bot') {
522+
robot.log.debug('App installation repos changed by Bot')
523+
return
524+
}
525+
robot.log.debug('App installation repos changed by a Human — triggering sync to revert drift')
526+
527+
// Build a context that targets the admin repo for this org
528+
const orgLogin = payload.installation.account.login
529+
const updatedContext = Object.assign({}, context, {
530+
repo: () => { return { repo: env.ADMIN_REPO, owner: orgLogin } }
531+
})
532+
return syncAllSettings(false, updatedContext)
533+
})
534+
535+
robot.on('installation_target', async context => {
536+
const { payload } = context
537+
const { sender } = payload
538+
robot.log.debug('Installation target changed by ', JSON.stringify(sender))
539+
if (sender.type === 'Bot') {
540+
robot.log.debug('Installation target changed by Bot')
541+
return
542+
}
543+
robot.log.debug('Installation target changed by a Human — triggering sync to revert drift')
544+
545+
const orgLogin = (payload.organization && payload.organization.login) ||
546+
(payload.installation && payload.installation.account && payload.installation.account.login)
547+
if (!orgLogin) {
548+
robot.log.debug('Could not determine org login from installation_target event, skipping')
549+
return
550+
}
551+
const updatedContext = Object.assign({}, context, {
552+
repo: () => { return { repo: env.ADMIN_REPO, owner: orgLogin } }
553+
})
554+
return syncAllSettings(false, updatedContext)
555+
})
556+
485557
robot.on('check_suite.requested', async context => {
486558
const { payload } = context
487559
const { repository } = payload

lib/appOctokitClient.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
const BATCH_SIZE = 50
2+
3+
/**
4+
* AppOctokitClient wraps an Octokit client authenticated as the GitHub App
5+
* (JWT) and provides methods for managing app installation repository access
6+
* via the Enterprise Organization Installations API.
7+
*
8+
* Prerequisites:
9+
* - safe-settings must be installed on the enterprise with
10+
* "Enterprise organization installations" permission.
11+
* - The enterprise slug is obtained from the webhook event payload
12+
* (payload.enterprise.slug).
13+
*
14+
* @param {object} options
15+
* @param {object} options.github - Octokit client authenticated as the app (via robot.auth())
16+
* @param {string} options.enterpriseSlug - Enterprise slug from webhook payload
17+
* @param {object} options.log - Logger instance
18+
*/
19+
class AppOctokitClient {
20+
constructor ({ github, enterpriseSlug, log }) {
21+
this.github = github
22+
this.enterpriseSlug = enterpriseSlug
23+
this.log = log
24+
}
25+
26+
/**
27+
* List all app installations in the enterprise for a given org.
28+
* Returns array of installation objects with { id, app_slug, app_id, ... }
29+
*
30+
* @param {string} org - Organization login name
31+
* @returns {Promise<Array>} List of installations
32+
*/
33+
async listOrgInstallations (org) {
34+
try {
35+
const options = this.github.request.endpoint.merge(
36+
'GET /enterprises/{enterprise}/apps/installations',
37+
{
38+
enterprise: this.enterpriseSlug,
39+
headers: { 'X-GitHub-Api-Version': '2026-03-10' }
40+
}
41+
)
42+
const installations = await this.github.paginate(options)
43+
// Filter to installations for the specified org
44+
return installations.filter(i =>
45+
i.account && i.account.login === org
46+
)
47+
} catch (e) {
48+
if (e.status === 403 || e.status === 404) {
49+
throw new Error(
50+
`Cannot access enterprise installations API. Ensure safe-settings is installed on the enterprise '${this.enterpriseSlug}' with 'Enterprise organization installations' permission. Error: ${e.message}`
51+
)
52+
}
53+
throw e
54+
}
55+
}
56+
57+
/**
58+
* List repositories accessible to an app installation.
59+
*
60+
* @param {number} installationId - The installation ID
61+
* @returns {Promise<Array>} List of repository objects
62+
*/
63+
async listInstallationRepos (installationId) {
64+
try {
65+
const options = this.github.request.endpoint.merge(
66+
'GET /enterprises/{enterprise}/apps/installations/{installation_id}/repositories',
67+
{
68+
enterprise: this.enterpriseSlug,
69+
installation_id: installationId,
70+
headers: { 'X-GitHub-Api-Version': '2026-03-10' }
71+
}
72+
)
73+
return this.github.paginate(options)
74+
} catch (e) {
75+
this.log.error(`Error listing repos for installation ${installationId}: ${e.message}`)
76+
throw e
77+
}
78+
}
79+
80+
/**
81+
* Grant repository access to an app installation.
82+
* Automatically batches into chunks of 50 (API limit).
83+
*
84+
* @param {number} installationId - The installation ID
85+
* @param {number[]} repositoryIds - Array of repository IDs to add
86+
* @returns {Promise<void>}
87+
*/
88+
async addReposToInstallation (installationId, repositoryIds) {
89+
if (!repositoryIds || repositoryIds.length === 0) return
90+
91+
const batches = this._chunk(repositoryIds, BATCH_SIZE)
92+
for (const batch of batches) {
93+
this.log.debug(`Adding ${batch.length} repos to installation ${installationId}`)
94+
await this.github.request(
95+
'POST /enterprises/{enterprise}/apps/installations/{installation_id}/repositories',
96+
{
97+
enterprise: this.enterpriseSlug,
98+
installation_id: installationId,
99+
repository_ids: batch,
100+
headers: { 'X-GitHub-Api-Version': '2026-03-10' }
101+
}
102+
)
103+
}
104+
}
105+
106+
/**
107+
* Remove repository access from an app installation.
108+
* Automatically batches into chunks of 50 (API limit).
109+
*
110+
* @param {number} installationId - The installation ID
111+
* @param {number[]} repositoryIds - Array of repository IDs to remove
112+
* @returns {Promise<void>}
113+
*/
114+
async removeReposFromInstallation (installationId, repositoryIds) {
115+
if (!repositoryIds || repositoryIds.length === 0) return
116+
117+
const batches = this._chunk(repositoryIds, BATCH_SIZE)
118+
for (const batch of batches) {
119+
this.log.debug(`Removing ${batch.length} repos from installation ${installationId}`)
120+
await this.github.request(
121+
'DELETE /enterprises/{enterprise}/apps/installations/{installation_id}/repositories',
122+
{
123+
enterprise: this.enterpriseSlug,
124+
installation_id: installationId,
125+
repository_ids: batch,
126+
headers: { 'X-GitHub-Api-Version': '2026-03-10' }
127+
}
128+
)
129+
}
130+
}
131+
132+
/**
133+
* Split an array into chunks of the given size.
134+
* @private
135+
*/
136+
_chunk (array, size) {
137+
const chunks = []
138+
for (let i = 0; i < array.length; i += size) {
139+
chunks.push(array.slice(i, i + size))
140+
}
141+
return chunks
142+
}
143+
}
144+
145+
module.exports = AppOctokitClient

0 commit comments

Comments
 (0)