Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
86 changes: 86 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,40 @@ 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, finds the enterprise
* installation from the app's installation list, and creates an Octokit
* client authenticated with the enterprise installation token.
*
* @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 {
// Get a JWT-authenticated client to list all installations
const appGithub = await robot.auth()
const installations = await appGithub.paginate(
appGithub.apps.listInstallations.endpoint.merge({ per_page: 100 })
)
// Find the installation targeting this enterprise
const enterpriseInstallation = installations.find(
i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterprise.slug
)
if (enterpriseInstallation) {
context.appGithub = await robot.auth(enterpriseInstallation.id)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should cache the enterpriseInstallation ID so that next time we can just reuse it.

} else {
robot.log.debug(`No enterprise installation found for slug '${enterprise.slug}'. App installation management will not be available.`)
}
} catch (e) {
robot.log.debug(`Could not create enterprise-authenticated client: ${e.message}`)
}
}
}

/**
* 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 +519,55 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
})

// ────────────────────────────────────────────────────────────────────────
// App installation drift detection handlers
// ────────────────────────────────────────────────────────────────────────

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 => {
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