Skip to content
Open
Show file tree
Hide file tree
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 Jun 25, 2026
a51ab87
fix: use enterprise installation token for app installation API calls
decyjphr Jun 25, 2026
8fee0dd
perf: cache enterprise installation ID to avoid repeated lookups
decyjphr Jun 25, 2026
d0be177
Wire syncAppInstallations into syncSelectedRepos for delta processing
decyjphr Jun 26, 2026
24fb684
Compute repository_unselection by diffing previous vs current configs
decyjphr Jun 26, 2026
4b2cec7
Fix delta bugs: org-all precedence + repo config path; update schema
decyjphr Jun 26, 2026
b731d54
Address app installation review: remove bad drift handler, fix orderi…
decyjphr Jun 26, 2026
9a25bdc
Skip redundant app installation churn for unchanged delta
decyjphr Jun 26, 2026
eacef7f
Use correct Enterprise Org Installations API (names, toggle, PATCH ad…
decyjphr Jun 26, 2026
c55645e
Add verification for app-installations plugin functionality
decyjphr Jul 6, 2026
c43625a
Refactor enterprise installation handling: add getEnterpriseAppClient…
decyjphr Jul 6, 2026
1364b09
Add ADR 0001: app installation management plugin
decyjphr Jul 6, 2026
99cd47e
Enhance app installations plugin: add subject handling and improve re…
decyjphr Jul 6, 2026
47a8d11
ADR updates
decyjphr Jul 6, 2026
7832694
Refactor enterprise installation handling: update caching logic and i…
decyjphr Jul 7, 2026
366522b
Add functions to list installations and find enterprise installation
decyjphr Jul 7, 2026
b4f00b2
Potential fix for pull request finding
decyjphr Jul 7, 2026
b6b87a0
Resolved CCR PR review
decyjphr Jul 7, 2026
d22a410
Removed repository_selection as a field in the settings.yml
decyjphr Jul 7, 2026
31652a7
Enhance app_installations handling: add detection for layered configu…
decyjphr Jul 7, 2026
1b63531
syncSelectedRepos() repoConfigs often contains only the *last* proces…
decyjphr Jul 7, 2026
26b5010
Handle authentication errors by clearing cached enterprise installati…
decyjphr Jul 7, 2026
60c4b7a
Map and Array mismatch will cause delta-based sync to skip real add/r…
decyjphr Jul 7, 2026
19d911b
Fix error that makes the app_installations phase silently no-op (and …
decyjphr Jul 7, 2026
97e2b81
protective to change to lowercase
decyjphr Jul 7, 2026
4e8240c
cleaned up test
decyjphr Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)

// Enrich context with enterprise info for app installation management
await enrichContextWithEnterprise(context)

// Load base branch config for NOP filtering (only show PR-introduced changes)
let baseConfig = null
if (nop && baseRef) {
Expand Down Expand Up @@ -142,6 +145,26 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
}
/**
* Enriches the context with enterprise info for app installation management.
* Extracts enterprise slug from the webhook payload and creates an
* app-authenticated Octokit client for enterprise API calls.
*
* @param {object} context - Probot context
*/
async function enrichContextWithEnterprise (context) {
const { payload } = context
const enterprise = payload.enterprise || (payload.installation && payload.installation.enterprise)
if (enterprise && enterprise.slug) {
context.enterpriseSlug = enterprise.slug
try {
context.appGithub = await robot.auth()
Comment thread
decyjphr marked this conversation as resolved.
Outdated
} catch (e) {
robot.log.debug(`Could not create app-authenticated client for enterprise: ${e.message}`)
}
}
}
Comment thread
Copilot marked this conversation as resolved.

/**
* Loads the deployment config file from file system
* Do this once when the app starts and then return the cached value
Expand Down Expand Up @@ -482,6 +505,55 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
})

// ────────────────────────────────────────────────────────────────────────
// App installation drift detection handlers
// ────────────────────────────────────────────────────────────────────────
Comment on lines +631 to +640

const installation_change_events = [
'installation.repositories_added',
'installation.repositories_removed'
]

robot.on(installation_change_events, async context => {
const { payload } = context
const { sender } = payload
robot.log.debug('App installation repos changed by ', JSON.stringify(sender))
if (sender.type === 'Bot') {
robot.log.debug('App installation repos changed by Bot')
return
}
robot.log.debug('App installation repos changed by a Human — triggering sync to revert drift')

// Build a context that targets the admin repo for this org
const orgLogin = payload.installation.account.login
const updatedContext = Object.assign({}, context, {
repo: () => { return { repo: env.ADMIN_REPO, owner: orgLogin } }
})
return syncAllSettings(false, updatedContext)
})

robot.on('installation_target', async context => {
Comment thread
decyjphr marked this conversation as resolved.
const { payload } = context
const { sender } = payload
robot.log.debug('Installation target changed by ', JSON.stringify(sender))
if (sender.type === 'Bot') {
robot.log.debug('Installation target changed by Bot')
return
}
robot.log.debug('Installation target changed by a Human — triggering sync to revert drift')

const orgLogin = (payload.organization && payload.organization.login) ||
(payload.installation && payload.installation.account && payload.installation.account.login)
if (!orgLogin) {
robot.log.debug('Could not determine org login from installation_target event, skipping')
return
}
const updatedContext = Object.assign({}, context, {
repo: () => { return { repo: env.ADMIN_REPO, owner: orgLogin } }
})
return syncAllSettings(false, updatedContext)
})

robot.on('check_suite.requested', async context => {
const { payload } = context
const { repository } = payload
Expand Down
145 changes: 145 additions & 0 deletions lib/appOctokitClient.js
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
Loading
Loading