permission string
-The permission to grant the team on this repository. We accept the following permissions to be set: pull, triage, push, maintain, admin and you can also specify a custom repository role name, if the owning organization has defined any. If no permission is specified, the team's permission attribute will be used to determine what permission to grant the team on this repository.
+The permission to grant the team on this repository. We accept the following permissions to be set: pull, triage, push, maintain, admin and you can also specify a custom repository role name, if the owning organization has defined any.
Default: push
diff --git a/index.js b/index.js
index ca2f6dc61..8cfdf0610 100644
--- a/index.js
+++ b/index.js
@@ -11,7 +11,7 @@ let deploymentConfig
module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
let appSlug = 'safe-settings'
- async function syncAllSettings (nop, context, repo = context.repo(), ref) {
+ async function syncAllSettings (nop, context, repo = context.repo(), ref, baseRef, changedFiles = {}) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
@@ -19,8 +19,21 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
+
+ // Load base branch config for NOP filtering (only show PR-introduced changes)
+ let baseConfig = null
+ if (nop && baseRef) {
+ try {
+ const baseConfigManager = new ConfigManager(context, baseRef)
+ const baseRuntimeConfig = await baseConfigManager.loadGlobalSettingsYaml()
+ baseConfig = Object.assign({}, deploymentConfig, baseRuntimeConfig)
+ } catch (e) {
+ robot.log.debug(`Could not load base config for NOP filtering: ${e.message}`)
+ }
+ }
+
if (ref) {
- return Settings.syncAll(nop, context, repo, config, ref)
+ return Settings.syncAll(nop, context, repo, config, ref, baseConfig, changedFiles)
} else {
return Settings.syncAll(nop, context, repo, config)
}
@@ -65,7 +78,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
- async function syncSelectedSettings (nop, context, repos, subOrgs, ref) {
+ async function syncSelectedSettings (nop, context, repos, subOrgs, ref, baseRef) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
@@ -73,7 +86,20 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
- return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref)
+
+ // Load base branch config for NOP filtering (only show PR-introduced changes)
+ let baseConfig = null
+ if (nop && baseRef) {
+ try {
+ const baseConfigManager = new ConfigManager(context, baseRef)
+ const baseRuntimeConfig = await baseConfigManager.loadGlobalSettingsYaml()
+ baseConfig = Object.assign({}, deploymentConfig, baseRuntimeConfig)
+ } catch (e) {
+ robot.log.debug(`Could not load base config for NOP filtering: ${e.message}`)
+ }
+ }
+
+ return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref, baseConfig)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
@@ -577,17 +603,21 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const files = changes.data.map(f => { return f.filename })
const settingsModified = files.includes(Settings.FILE_PATH)
+ const repoChanges = getChangedRepoConfigName(files, context.repo().owner)
+ const subOrgChanges = getChangedSubOrgConfigName(files)
if (settingsModified) {
robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`)
- return syncAllSettings(true, context, context.repo(), pull_request.head.ref)
+ const baseRef = pull_request.base.ref || repository.default_branch
+ return syncAllSettings(true, context, context.repo(), pull_request.head.ref, baseRef, {
+ repos: repoChanges,
+ subOrgs: subOrgChanges
+ })
}
- const repoChanges = getChangedRepoConfigName(files, context.repo().owner)
- const subOrgChanges = getChangedSubOrgConfigName(files)
-
if (repoChanges.length > 0 || subOrgChanges.length > 0) {
- return syncSelectedSettings(true, context, repoChanges, subOrgChanges, pull_request.head.ref)
+ const baseRef = pull_request.base.ref || repository.default_branch
+ return syncSelectedSettings(true, context, repoChanges, subOrgChanges, pull_request.head.ref, baseRef)
}
// if no safe-settings changes detected, send a success to the check run
diff --git a/lib/commentmessage.js b/lib/commentmessage.js
index 66f4862c8..292c9ed5f 100644
--- a/lib/commentmessage.js
+++ b/lib/commentmessage.js
@@ -1,33 +1,27 @@
-module.exports = `* Run on: \` <%= new Date() %> \`
+module.exports = `<% const esc = s => String(s).replace(/&/g, "&").replace(//g, ">") %>Run on: \`<%= new Date().toISOString() %>\`
-* Number of repos that were considered: \`<%= Object.keys(it.reposProcessed).length %> \`
+* Number of repos considered: \`<%= Object.keys(it.reposProcessed).length %>\`
+* Number of repos affected: \`<%= it.reposAffected || 0 %>\`
### Breakdown of changes
-| Repo <% Object.keys(it.changes).forEach(plugin => { %> | <%= plugin %> settings <% }) %> |
-| -- <% Object.keys(it.changes).forEach(plugin => { -%> | -- <% }) %>
-|
-<% Object.keys(it.reposProcessed).forEach( repo => { -%>
-| <%= repo -%>
- <%- Object.keys(it.changes).forEach(plugin => { -%>
- <%_ if (it.changes[plugin][repo]) { -%> | :hand: <% } else { %> | :grey_exclamation: <% } -%>
- <%_ }) -%> |
-<% }) -%>
-
-:hand: -> Changes to be applied to the GitHub repository.
-:grey_exclamation: -> nothing to be changed in that particular GitHub repository.
+
+<% if (!it.checkRunDetails || it.checkRunDetails.length === 0) { %>
+No changes to apply.
+<% } else { %>
+<%~ it.checkRunDetails %>
+<% } %>
### Breakdown of errors
<% if (Object.keys(it.errors).length === 0) { %>
\`None\`
<% } else { %>
- <% Object.keys(it.errors).forEach(repo => { %>
- <%_= repo %>:
- <% it.errors[repo].forEach(plugin => { %>
- * <%= plugin.msg %>
- <% }) %>
+
+:warning: Errors by repo — <%= Object.keys(it.errors).length %> repo(s) affected
- <% }) %>
+<%~ Object.keys(it.errors).map(repo => "**" + esc(repo) + "**:\\n" + it.errors[repo].map(err => "* " + esc(err.msg)).join("\\n")).join("\\n\\n") %>
+
+
<% } %>
### Informational messages (disabled plugins)
@@ -35,11 +29,10 @@ module.exports = `* Run on: \` <%= new Date() %> \`
<% if (!it.infos || Object.keys(it.infos).length === 0) { %>
\`None\`
<% } else { %>
- <% Object.keys(it.infos).forEach(repo => { %>
- <%_= repo %>:
- <% it.infos[repo].forEach(msg => { %>
- * ℹ️ <%= msg %>
- <% }) %>
+
+:information_source: Info — <%= Object.keys(it.infos).length %> repo(s)
+
+<%~ Object.keys(it.infos).map(repo => "**" + esc(repo) + "**:\\n" + it.infos[repo].map(msg => "* ℹ️ " + esc(msg)).join("\\n")).join("\\n\\n") %>
- <% }) %>
+
<% } %>`
diff --git a/lib/settings.js b/lib/settings.js
index 912505e9f..7e1eae273 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -40,6 +40,8 @@ const DISABLE_STRIP_MATRIX = {
const CONFIG_PATH = env.CONFIG_PATH
const eta = new Eta({ views: path.join(__dirname) })
const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting
+// Maximum size (in characters) of a single PR comment / check-run summary body.
+const COMMENT_LIMIT = 55536
const yaml = require('js-yaml')
// When a repo-yml change applies teams/properties/etc to a repo, the repo may
@@ -48,13 +50,516 @@ const yaml = require('js-yaml')
// tightest cap: we resolve a single hop of newly-matched suborg per sync.
const MAX_REEVALUATION_DEPTH = 1
+// ---------------------------------------------------------------------------
+// NOP change-detection helpers
+// ---------------------------------------------------------------------------
+
+// Recursively determines whether a value is "empty" (null/undefined, empty
+// array/object, or a structure containing only empty values).
+function isDeepEmpty (value) {
+ if (value === null || value === undefined) return true
+ if (Array.isArray(value)) return value.length === 0 || value.every(isDeepEmpty)
+ if (typeof value === 'object') {
+ const keys = Object.keys(value)
+ return keys.length === 0 || keys.every(k => isDeepEmpty(value[k]))
+ }
+ return false
+}
+
+// Determines whether a NopCommand action represents no meaningful change.
+// String actions (message-only NOP results) are treated as non-empty so they
+// are not silently dropped from reporting.
+function isEmptyChange (action) {
+ if (!action) return true
+ if (typeof action === 'string') return action.length === 0
+ const { additions, deletions, modifications } = action
+ if (additions === null && deletions === null && modifications === null) return true
+ return isDeepEmpty(additions) && isDeepEmpty(deletions) && isDeepEmpty(modifications)
+}
+
+// Produce a canonical (key-sorted) clone so deep equality is order-independent.
+function canonicalize (value) {
+ if (value === null || typeof value !== 'object') return value
+ if (Array.isArray(value)) return value.map(canonicalize)
+ return Object.keys(value).sort().reduce((acc, key) => {
+ acc[key] = canonicalize(value[key])
+ return acc
+ }, {})
+}
+
+function stableStringify (value) {
+ return JSON.stringify(canonicalize(value))
+}
+
+/**
+ * Determines which named entries in an array-based config section actually
+ * changed between the base branch and the PR branch. Returns a Set of entry
+ * names that differ. Uses name-indexed Maps (O(n)) and order-independent deep
+ * equality to avoid false positives from key ordering.
+ */
+function getChangedEntryNames (baseEntries, prEntries) {
+ const changed = new Set()
+ if (!baseEntries && !prEntries) return changed
+ if (!baseEntries || !Array.isArray(baseEntries)) {
+ // All PR entries are new
+ if (Array.isArray(prEntries)) prEntries.forEach(e => { if (e && e.name) changed.add(e.name) })
+ return changed
+ }
+ if (!prEntries || !Array.isArray(prEntries)) {
+ // All base entries are deleted
+ baseEntries.forEach(e => { if (e && e.name) changed.add(e.name) })
+ return changed
+ }
+ const baseByName = new Map()
+ baseEntries.forEach(e => { if (e && e.name) baseByName.set(e.name, e) })
+ const prByName = new Map()
+ prEntries.forEach(e => { if (e && e.name) prByName.set(e.name, e) })
+ // Added or modified entries
+ for (const [name, prEntry] of prByName) {
+ const baseEntry = baseByName.get(name)
+ if (!baseEntry || stableStringify(baseEntry) !== stableStringify(prEntry)) {
+ changed.add(name)
+ }
+ }
+ // Deleted entries
+ for (const name of baseByName.keys()) {
+ if (!prByName.has(name)) changed.add(name)
+ }
+ return changed
+}
+
+/**
+ * Filters a NOP action's arrays to only include entries whose 'name' is in the
+ * changedNames set. Returns a new action with filtered arrays, or null if
+ * nothing meaningful remains.
+ */
+function filterActionByChangedNames (action, changedNames) {
+ if (!action || typeof action === 'string') return action
+
+ const { additions, deletions, modifications, ...rest } = action
+
+ const filterArray = (arr) => {
+ if (!arr || !Array.isArray(arr)) return arr
+ return arr.filter(entry => {
+ if (!entry || typeof entry !== 'object') return true
+ // Keep entries whose name is in the changed set
+ if (entry.name && changedNames.has(entry.name)) return true
+ // Keep entries without a name field (structural entries like conditions)
+ if (!entry.name) return true
+ return false
+ })
+ }
+
+ const filtered = {
+ ...rest,
+ additions: filterArray(additions),
+ deletions: filterArray(deletions),
+ modifications: filterArray(modifications)
+ }
+
+ // Return null if everything was filtered out
+ if (isEmptyChange(filtered)) return null
+ return filtered
+}
+
+// ---------------------------------------------------------------------------
+// NOP change-rendering helpers (collapsible, field-level diff summaries)
+// ---------------------------------------------------------------------------
+
+function buildChangeSections (changes, baseConfig, config) {
+ return Object.keys(changes).map(plugin => {
+ const repoSections = []
+ Object.keys(changes[plugin]).forEach(repo => {
+ const targetMap = new Map()
+ changes[plugin][repo].forEach(action => {
+ targetsForAction(plugin, repo, action, baseConfig, config).forEach(target => {
+ if (!targetMap.has(target.target)) {
+ targetMap.set(target.target, {
+ target: target.target,
+ rows: []
+ })
+ }
+ targetMap.get(target.target).rows.push(...target.rows)
+ })
+ })
+ repoSections.push({
+ repo,
+ targets: Array.from(targetMap.values()).filter(target => target.rows.length > 0)
+ })
+ })
+
+ const filteredRepoSections = repoSections.filter(repoSection => repoSection.targets.length > 0)
+ const changeCount = filteredRepoSections.reduce((count, repoSection) => {
+ return count + repoSection.targets.reduce((targetCount, target) => targetCount + target.rows.length, 0)
+ }, 0)
+ const targetCount = filteredRepoSections.reduce((count, repoSection) => count + repoSection.targets.length, 0)
+ const repoCount = filteredRepoSections.length
+ const targetSingular = plugin.toLowerCase() === 'rulesets' ? 'policy' : 'setting'
+ const targetPlural = plugin.toLowerCase() === 'rulesets' ? 'policies' : 'settings'
+ const impactSummary = `${repoCount} ${pluralize(repoCount, 'repo', 'repos')}, ${targetCount} ${pluralize(targetCount, targetSingular, targetPlural)} changed`
+ return {
+ plugin,
+ repoSections: filteredRepoSections,
+ repoCount,
+ targetCount,
+ changeCount,
+ impactSummary,
+ summary: `${plugin} - ${impactSummary}`
+ }
+ }).filter(section => section.repoSections.length > 0)
+}
+
+function renderChangeSections (changeSections) {
+ return changeSections.map(section => {
+ const repoBlocks = section.repoSections.map(repoSection => {
+ const targetBlocks = repoSection.targets.map(target => {
+ return `- ${markdownInlineCode(target.target)}\n${renderFieldChangeList(target.rows, ' ')}`
+ })
+ return `**${markdownText(displayRepoName(repoSection.repo))}**\n${targetBlocks.join('\n')}`
+ })
+
+ return `\n${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)} \n\n${repoBlocks.join('\n\n')}\n\n `
+ })
+}
+
+function affectedRepoCount (changeSections) {
+ return new Set(changeSections.flatMap(section => {
+ return section.repoSections.map(repoSection => displayRepoName(repoSection.repo))
+ })).size
+}
+
+function displayRepoName (repo) {
+ return repo && repo.endsWith('(org)') ? env.ADMIN_REPO : repo
+}
+
+function renderFieldChangeList (rows, indent = '') {
+ return rows.map(row => {
+ const marker = changeMarker(row.change)
+ if (row.change === 'Info') {
+ return `${indent}- ${marker} ${markdownText(row.after || row.before || row.field)}`
+ }
+ if (row.change === 'Modified') {
+ return `${indent}- ${marker} ${markdownInlineCode(row.field)}\n${indent} - before: ${markdownInlineCode(row.before, row.after)}\n${indent} - after: ${markdownInlineCode(row.after, row.before)}`
+ }
+ const value = row.change === 'Deleted' ? row.before : row.after
+ return `${indent}- ${marker} ${markdownInlineCode(row.field)}: ${markdownInlineCode(value)}`
+ }).join('\n')
+}
+
+function changeMarker (change) {
+ if (change === 'Added') return '+'
+ if (change === 'Deleted') return '-'
+ if (change === 'Modified') return '~'
+ return 'i'
+}
+
+function targetsForAction (plugin, repo, action, baseConfig, config) {
+ if (typeof action === 'string') {
+ return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action)])]
+ }
+
+ const configTargets = targetsFromConfigDiff(plugin, repo, action, baseConfig, config)
+ if (configTargets) return configTargets
+
+ const additions = normalizeChangeEntries(action && action.additions)
+ const deletions = normalizeChangeEntries(action && action.deletions)
+ const modifications = normalizeChangeEntries(action && action.modifications)
+
+ const usedDeletions = new Set()
+ const targets = []
+
+ additions.forEach(entry => {
+ const target = getChangeTarget(entry, plugin)
+ targets.push(createTarget(target, rowsForAddedOrDeleted('Added', entry, target)))
+ })
+
+ modifications.forEach((entry, index) => {
+ const target = getChangeTarget(entry, plugin)
+ const match = findMatchingDeletion(entry, index, modifications, deletions, usedDeletions)
+ if (match.index !== -1) usedDeletions.add(match.index)
+ targets.push(createTarget(target, rowsForModification(match.entry, entry, target)))
+ })
+
+ deletions.forEach((entry, index) => {
+ if (usedDeletions.has(index)) return
+ const target = getChangeTarget(entry, plugin)
+ targets.push(createTarget(target, rowsForAddedOrDeleted('Deleted', entry, target)))
+ })
+
+ if (targets.length === 0 && action && action.msg) {
+ return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action.msg)])]
+ }
+
+ return targets
+}
+
+function targetsFromConfigDiff (plugin, repo, action, baseConfig, config) {
+ if (!baseConfig || !config || !action || typeof action === 'string') return null
+
+ const pluginSection = plugin.toLowerCase()
+ const isOrgRulesets = repo && repo.endsWith('(org)') && pluginSection === 'rulesets'
+ const baseEntries = baseConfig[pluginSection]
+ const prEntries = config[pluginSection]
+
+ if (!isOrgRulesets) return null
+ if (!Array.isArray(baseEntries) || !Array.isArray(prEntries)) return null
+
+ const actionNames = getActionEntryNames(action)
+ if (actionNames.size === 0) return null
+
+ const changedNames = new Set(Array.from(getChangedEntryNames(baseEntries, prEntries)).filter(name => actionNames.has(name)))
+ if (changedNames.size === 0) return null
+
+ const targets = []
+ Array.from(changedNames).sort().forEach(name => {
+ const oldEntry = findEntryByIdentity(baseEntries, name)
+ const newEntry = findEntryByIdentity(prEntries, name)
+ let rows = []
+
+ if (oldEntry && newEntry) {
+ rows = rowsForModification(oldEntry, newEntry, name)
+ } else if (newEntry) {
+ rows = rowsForAddedOrDeleted('Added', newEntry, name)
+ } else if (oldEntry) {
+ rows = rowsForAddedOrDeleted('Deleted', oldEntry, name)
+ }
+
+ if (rows.length > 0) targets.push(createTarget(name, rows))
+ })
+
+ return targets.length > 0 ? targets : null
+}
+
+function getActionEntryNames (action) {
+ const names = new Set()
+ ;['additions', 'deletions', 'modifications'].forEach(actionField => {
+ normalizeChangeEntries(action[actionField]).forEach(entry => {
+ const identity = getEntryIdentityValue(entry)
+ if (identity) names.add(identity)
+ })
+ })
+ return names
+}
+
+function findEntryByIdentity (entries, identity) {
+ return entries.find(entry => getEntryIdentityValue(entry) === identity)
+}
+
+function createTarget (target, rows) {
+ return {
+ target,
+ rows: rows.filter(row => row)
+ }
+}
+
+function normalizeChangeEntries (value) {
+ if (isDeepEmpty(value)) return []
+ return Array.isArray(value) ? value.filter(entry => !isDeepEmpty(entry)) : [value]
+}
+
+function findMatchingDeletion (entry, index, modifications, deletions, usedDeletions) {
+ const identity = getChangeIdentity(entry)
+ if (identity) {
+ const matchIndex = deletions.findIndex((deletion, deletionIndex) => {
+ if (usedDeletions.has(deletionIndex)) return false
+ return getChangeIdentity(deletion) === identity
+ })
+ if (matchIndex !== -1) return { entry: deletions[matchIndex], index: matchIndex }
+ }
+
+ if (modifications.length === 1 && deletions.length === 1 && !usedDeletions.has(0)) {
+ return { entry: deletions[0], index: 0 }
+ }
+
+ return { entry: null, index: -1 }
+}
+
+function getChangeIdentity (entry) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
+ const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field))
+ if (!field) return null
+ return `${field}:${formatValue(entry[field]).text}`
+}
+
+function getChangeTarget (entry, fallback) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return formatValue(entry).text || fallback
+ return getEntryIdentityValue(entry) || fallback
+}
+
+function getEntryIdentityValue (entry) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
+ const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field))
+ return field ? formatValue(entry[field]).text : null
+}
+
+function rowsForAddedOrDeleted (change, entry, target) {
+ const flattened = flattenForSummary(entry, true)
+ const fields = Object.keys(flattened)
+ if (fields.length === 0) return [createFieldChangeRow(change, 'value', change === 'Added' ? '' : target, change === 'Added' ? target : '')]
+
+ return fields.map(path => {
+ const value = flattened[path]
+ return createFieldChangeRow(change, path, change === 'Deleted' ? value : '', change === 'Deleted' ? '' : value)
+ })
+}
+
+function rowsForModification (oldEntry, newEntry, target) {
+ if (!oldEntry || typeof oldEntry !== 'object' || !newEntry || typeof newEntry !== 'object') {
+ return rowsForAddedOrDeleted('Modified', newEntry, target)
+ }
+
+ const oldPaths = flattenForSummary(oldEntry, true)
+ const newPaths = flattenForSummary(newEntry, true)
+ const paths = Array.from(new Set([...Object.keys(oldPaths), ...Object.keys(newPaths)])).sort()
+ const rows = paths.map(path => {
+ const hasOld = Object.prototype.hasOwnProperty.call(oldPaths, path)
+ const hasNew = Object.prototype.hasOwnProperty.call(newPaths, path)
+ if (hasOld && hasNew && comparableValue(oldPaths[path]) !== comparableValue(newPaths[path])) {
+ return createFieldChangeRow('Modified', path, oldPaths[path], newPaths[path])
+ }
+ if (!hasOld && hasNew) {
+ return createFieldChangeRow('Added', path, '', newPaths[path])
+ }
+ if (hasOld && !hasNew) {
+ return createFieldChangeRow('Deleted', path, oldPaths[path], '')
+ }
+ return null
+ }).filter(row => row)
+
+ if (rows.length > 0) return rows
+ return rowsForAddedOrDeleted('Modified', newEntry, target)
+}
+
+function createFieldChangeRow (change, field, before, after) {
+ return {
+ change,
+ field,
+ before,
+ after
+ }
+}
+
+function flattenForSummary (value, skipRootIdentity = false, prefix = '') {
+ if (value === null || value === undefined || typeof value !== 'object') {
+ return { [prefix || 'value']: formatValue(value) }
+ }
+
+ if (Array.isArray(value)) {
+ return { [prefix || 'value']: formatValue(value) }
+ }
+
+ const result = {}
+ Object.keys(value).forEach(key => {
+ if (!prefix && skipRootIdentity && MergeDeep.NAME_FIELDS.includes(key)) return
+ const path = prefix ? `${prefix}.${key}` : key
+ const child = value[key]
+
+ if (child && typeof child === 'object' && !Array.isArray(child)) {
+ Object.assign(result, flattenForSummary(child, false, path))
+ } else {
+ result[path] = formatValue(child)
+ }
+ })
+
+ return result
+}
+
+function formatValue (value) {
+ if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'text')) return value
+ if (value === null) return { text: 'null', compare: 'null' }
+ if (value === undefined) return { text: '', compare: '' }
+ if (typeof value === 'string') return { text: value, compare: value }
+ if (typeof value === 'number' || typeof value === 'boolean') return { text: `${value}`, compare: `${value}` }
+ if (Array.isArray(value) && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) {
+ const text = value.map(item => formatValue(item).text).join(', ')
+ return { text, compare: text }
+ }
+ const json = JSON.stringify(value)
+ return {
+ text: truncate(json, 180),
+ compare: json
+ }
+}
+
+function comparableValue (value) {
+ const displayValue = formatValue(value)
+ return Object.prototype.hasOwnProperty.call(displayValue, 'compare') ? displayValue.compare : displayValue.text
+}
+
+function truncate (value, limit = 180) {
+ if (!value || value.length <= limit) return value
+ return `${value.substring(0, limit - 3)}...`
+}
+
+function truncateAroundDifference (value, otherValue, limit = 180) {
+ if (!value || value.length <= limit) return value
+ if (!otherValue || value === otherValue) return truncate(value, limit)
+
+ let prefixLength = 0
+ while (
+ prefixLength < value.length &&
+ prefixLength < otherValue.length &&
+ value[prefixLength] === otherValue[prefixLength]
+ ) {
+ prefixLength++
+ }
+
+ let suffixLength = 0
+ while (
+ suffixLength < value.length - prefixLength &&
+ suffixLength < otherValue.length - prefixLength &&
+ value[value.length - 1 - suffixLength] === otherValue[otherValue.length - 1 - suffixLength]
+ ) {
+ suffixLength++
+ }
+
+ const contextLength = Math.floor((limit - 6) / 2)
+ const start = Math.max(0, prefixLength - contextLength)
+ const end = Math.min(value.length, value.length - suffixLength + contextLength)
+ const prefix = start > 0 ? '...' : ''
+ const suffix = end < value.length ? '...' : ''
+ return truncate(`${prefix}${value.substring(start, end)}${suffix}`, limit)
+}
+
+function truncateWithSuffix (value, limit, suffix) {
+ if (!value || value.length <= limit) return value
+ return `${value.substring(0, limit - suffix.length)}${suffix}`
+}
+
+function pluralize (count, singular, plural) {
+ return count === 1 ? singular : plural
+}
+
+function markdownInlineCode (value, comparedWith) {
+ return `\`${markdownText(value, comparedWith).replaceAll('`', '\\`')}\``
+}
+
+function markdownText (value, comparedWith) {
+ const displayValue = formatValue(value)
+ const otherDisplayValue = comparedWith === undefined ? null : formatValue(comparedWith)
+ const text = otherDisplayValue
+ ? truncateAroundDifference(displayValue.compare || displayValue.text, otherDisplayValue.compare || otherDisplayValue.text)
+ : displayValue.text
+ return escapeHtml(text)
+ .replaceAll('\n', ' ')
+}
+
+function escapeHtml (value) {
+ return `${value}`
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+}
+
class Settings {
static fileCache = {}
- static async syncAll (nop, context, repo, config, ref) {
- const settings = new Settings(nop, context, repo, config, ref)
+ static async syncAll (nop, context, repo, config, ref, baseConfig, changedFiles = {}) {
+ const settings = new Settings(nop, context, repo, config, ref, null, baseConfig)
+ settings.setChangedConfigTargets(changedFiles.repos, changedFiles.subOrgs)
try {
await settings.loadConfigs()
+ settings.trackChangedReposFromSubOrgConfigs()
// settings.repoConfigs = await settings.getRepoConfigs()
await settings.updateOrg()
await settings.updateAll()
@@ -78,10 +583,16 @@ class Settings {
}
}
- static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref) {
- const settings = new Settings(nop, context, context.repo(), config, ref)
+ static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref, baseConfig) {
+ const settings = new Settings(nop, context, context.repo(), config, ref, null, baseConfig)
+ settings.setChangedConfigTargets(repos, subOrgs)
try {
+ // Track repos affected by changed suborg config files so base-config
+ // filtering knows which repo-level results to keep during NOP runs.
+ settings.subOrgConfigs = await settings.getSubOrgConfigs()
+ settings.trackChangedReposFromSubOrgConfigs()
+
// Re-eval is enabled only for the per-repo iteration (repo-yml change
// path). The trailing suborg iteration below already iterates all suborg
// repos, so it is left with the flag off.
@@ -133,13 +644,14 @@ class Settings {
await settings.handleResults()
}
- constructor (nop, context, repo, config, ref, suborg) {
+ constructor (nop, context, repo, config, ref, suborg, baseConfig) {
this.ref = ref
this.context = context
this.installation_id = context.payload.installation.id
this.github = context.octokit
this.repo = repo
this.config = config
+ this.baseConfig = baseConfig || null
this.nop = nop
this.suborgChange = !!suborg
// If suborg config has been updated, do not load the entire suborg config, and only process repos restricted to it.
@@ -178,6 +690,46 @@ class Settings {
this.reevaluatedRepos = new Map()
}
+ // Record which repo override files and suborg config files changed in the PR.
+ // Used during NOP runs to keep repo-level results whose config actually
+ // changed (and filter out pre-existing drift).
+ setChangedConfigTargets (changedRepos = [], changedSubOrgs = []) {
+ const repoNames = Array.isArray(changedRepos)
+ ? changedRepos.map(repo => repo && repo.repo).filter(Boolean)
+ : []
+
+ this.changedRepoNames = new Set(repoNames)
+ this.changedSubOrgConfigs = Array.isArray(changedSubOrgs) ? changedSubOrgs : []
+ }
+
+ // Expand changedSubOrgConfigs (changed suborg config files) into the set of
+ // repos they affect, adding them to changedRepoNames.
+ trackChangedReposFromSubOrgConfigs () {
+ if (!Array.isArray(this.changedSubOrgConfigs) || this.changedSubOrgConfigs.length === 0 || !this.subOrgConfigs) {
+ return
+ }
+
+ const changedSubOrgPaths = new Set(
+ this.changedSubOrgConfigs
+ .map(subOrg => subOrg && subOrg.path)
+ .filter(Boolean)
+ )
+
+ if (changedSubOrgPaths.size === 0) {
+ return
+ }
+
+ if (!this.changedRepoNames) {
+ this.changedRepoNames = new Set()
+ }
+
+ Object.entries(this.subOrgConfigs).forEach(([repoName, subOrgConfig]) => {
+ if (subOrgConfig && subOrgConfig.source && changedSubOrgPaths.has(subOrgConfig.source)) {
+ this.changedRepoNames.add(repoName)
+ }
+ })
+ }
+
// Create a check in the Admin repo for safe-settings.
async createCheckRun () {
const startTime = new Date()
@@ -264,6 +816,54 @@ class Settings {
})
})
+ // When a base-branch config is available (NOP / dry-run on a PR), filter
+ // out results that reflect pre-existing drift rather than changes the PR
+ // actually introduces.
+ if (this.baseConfig) {
+ this.log.debug('Filtering NOP results using base config comparison')
+ this.results = this.results.filter(res => {
+ if (!res || res.type === 'ERROR') return true
+
+ const isOrgLevel = res.repo && res.repo.endsWith('(org)')
+ const pluginSection = res.plugin ? res.plugin.toLowerCase() : null
+
+ if (isOrgLevel && pluginSection === 'rulesets') {
+ // Org-level rulesets: keep only rulesets whose definition changed.
+ const changedNames = getChangedEntryNames(this.baseConfig.rulesets, this.config.rulesets)
+ if (changedNames.size === 0) return false
+ const filtered = filterActionByChangedNames(res.action, changedNames)
+ if (!filtered) return false
+ res.action = filtered
+ return true
+ }
+
+ if (!isOrgLevel && pluginSection) {
+ // Keep results for repos whose override/suborg config files changed.
+ if (this.changedRepoNames && this.changedRepoNames.has(res.repo)) {
+ return true
+ }
+
+ // Repo-level rulesets originate from override files, not the global
+ // config — when no override changed for this repo it is drift.
+ if (pluginSection === 'rulesets') {
+ return false
+ }
+
+ // Other repo-level plugins: drop when the global config section for
+ // this plugin is unchanged between base and PR.
+ const baseSection = this.baseConfig[pluginSection]
+ const prSection = this.config[pluginSection]
+ if (baseSection !== undefined && prSection !== undefined) {
+ if (JSON.stringify(baseSection) === JSON.stringify(prSection)) {
+ return false
+ }
+ }
+ }
+
+ return true
+ })
+ }
+
let error = false
const stats = {
reposProcessed: {},
@@ -281,7 +881,10 @@ class Settings {
if (!stats.errors[res.repo]) {
stats.errors[res.repo] = []
}
- stats.errors[res.repo].push(res.action)
+ const msg = res.action && (res.action.msg || res.action.message)
+ ? (res.action.msg || res.action.message)
+ : `${res.action}`
+ stats.errors[res.repo].push({ msg })
} else if (res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null) {
// No diff data — informational message (e.g. disable_plugins skip).
if (res.action?.msg) {
@@ -290,71 +893,101 @@ class Settings {
}
stats.infos[res.repo].push(`[${res.plugin}] ${res.action.msg}`)
}
- } else {
+ } else if (!isEmptyChange(res.action)) {
if (!stats.changes[res.plugin]) {
stats.changes[res.plugin] = {}
}
if (!stats.changes[res.plugin][res.repo]) {
stats.changes[res.plugin][res.repo] = []
}
- stats.changes[res.plugin][res.repo].push(`${res.action}`)
+ stats.changes[res.plugin][res.repo].push(res.action)
}
}
})
this.log.debug(`Stats ${JSON.stringify(this.results, null, 2)}`)
- const table = `
-
-
- Msg
- Plugin
- Repo
- Additions
- Deletions
- Modifications
-
-
-
- `
+ stats.changeSections = buildChangeSections(stats.changes, this.baseConfig, this.config)
+ stats.reposAffected = affectedRepoCount(stats.changeSections)
+ stats.changeDetails = stats.changeSections.length > 0
+ ? renderChangeSections(stats.changeSections).join('\n\n')
+ : ''
+ stats.checkRunDetails = stats.changeDetails.length > 50000
+ ? 'Detailed changed-field output is available in the pull request comment.'
+ : stats.changeDetails
const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats)
if (env.CREATE_PR_COMMENT === 'true') {
- const summary = `
-#### :robot: Safe-Settings config changes detected:
-
-${this.results.reduce((x, y) => {
- if (!y) {
- return x
+ const pluginSectionList = renderChangeSections(stats.changeSections)
+
+ const errorRepos = Object.keys(stats.errors)
+ const errorSection = errorRepos.length === 0
+ ? '### Errors\n`None`'
+ : `### Errors\n\n:warning: Errors — ${errorRepos.length} ${pluralize(errorRepos.length, 'repo', 'repos')} affected \n\n${
+ errorRepos.map(repo =>
+ `**${repo}**:\n${stats.errors[repo].map(e => `* ${e.msg}`).join('\n')}`
+ ).join('\n\n')
+ }\n\n `
+
+ // Preserve disable_plugins informational messages in the PR comment.
+ const infoRepos = Object.keys(stats.infos)
+ const infoSection = infoRepos.length === 0
+ ? ''
+ : `### Informational messages (disabled plugins)\n\n:information_source: Info — ${infoRepos.length} ${pluralize(infoRepos.length, 'repo', 'repos')} \n\n${
+ infoRepos.map(repo =>
+ `**${repo}**:\n${stats.infos[repo].map(msg => `* :information_source: ${msg}`).join('\n')}`
+ ).join('\n\n')
+ }\n\n `
+
+ const trailingSections = infoSection ? [errorSection, infoSection] : [errorSection]
+ const bodySections = stats.changeSections.length === 0
+ ? ['_No changes to apply._', ...trailingSections]
+ : [...pluginSectionList, ...trailingSections]
+
+ const repoCount = Object.keys(stats.reposProcessed).length
+ const makeHeader = (page, total) => total > 1
+ ? `#### :robot: Safe-Settings config changes detected (${page}/${total}):\n\n**Repos considered:** ${repoCount}\n**Repos affected:** ${stats.reposAffected}\n\n`
+ : `#### :robot: Safe-Settings config changes detected:\n\n**Repos considered:** ${repoCount}\n**Repos affected:** ${stats.reposAffected}\n\n`
+
+ // Reserve room for the largest possible header so pages never overflow
+ // the comment limit regardless of the final page count.
+ const headerOverhead = makeHeader(9999, 9999).length
+ const bodyLimit = COMMENT_LIMIT - headerOverhead
+
+ const pages = []
+ let currentChunks = []
+ let currentLength = 0
+ const flushPage = () => {
+ if (currentChunks.length > 0) {
+ pages.push(currentChunks.join('\n\n'))
+ currentChunks = []
+ currentLength = 0
}
- if (y.type === 'ERROR') {
- error = true
- return `${x}
- ❗ ${y.action.msg} ${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)} `
- } else if (y.action?.additions === null && y.action?.deletions === null && y.action?.modifications === null) {
- if (!y.action?.msg) return `${x}`
- return `${x}
- ℹ️ ${y.action.msg} ${y.plugin} ${prettify(y.repo)} — — — `
- } else {
- if (y.action === undefined) {
- return `${x}`
- }
- return `${x}
- ✋ ${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)} `
+ }
+ for (const section of bodySections) {
+ const sectionLength = section.length + 2
+ if (currentChunks.length > 0 && currentLength + sectionLength > bodyLimit) {
+ flushPage()
}
- }, table)}
-
-`
+ currentChunks.push(section)
+ currentLength += sectionLength
+ }
+ flushPage()
+ if (pages.length === 0) pages.push('')
+ const totalPages = pages.length
const pullRequest = payload.check_run.check_suite.pull_requests[0]
- await this.github.issues.createComment({
- owner: payload.repository.owner.login,
- repo: payload.repository.name,
- issue_number: pullRequest.number,
- body: summary.length > 55536 ? `${summary.substring(0, 55536)}... (too many changes to report)` : summary
- })
+ for (let i = 0; i < pages.length; i++) {
+ const body = `${makeHeader(i + 1, totalPages)}${pages[i]}`
+ await this.github.issues.createComment({
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ issue_number: pullRequest.number,
+ body: truncateWithSuffix(body, COMMENT_LIMIT, '... (too many changes to report)')
+ })
+ }
}
const params = {
@@ -366,7 +999,7 @@ ${this.results.reduce((x, y) => {
completed_at: new Date().toISOString(),
output: {
title: error ? 'Safe-Settings Dry-Run Finished with Error' : 'Safe-Settings Dry-Run Finished with success',
- summary: renderedCommentMessage.length > 55536 ? `${renderedCommentMessage.substring(0, 55536)}... (too many changes to report)` : renderedCommentMessage
+ summary: truncateWithSuffix(renderedCommentMessage, COMMENT_LIMIT, '... (too many changes to report)')
}
}
@@ -554,6 +1187,9 @@ ${this.results.reduce((x, y) => {
} else {
const RulesetsPlugin = Settings.PLUGINS.rulesets
await new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG).sync().then(res => {
+ if (this.nop && Array.isArray(res)) {
+ res.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` })
+ }
this.appendToResults(res)
})
}
@@ -1456,13 +2092,6 @@ ${this.results.reduce((x, y) => {
}
}
-function prettify (obj) {
- if (obj === null || obj === undefined) {
- return ''
- }
- return JSON.stringify(obj, null, 2).replaceAll('\n', ' ').replaceAll(' ', ' ')
-}
-
Settings.FILE_NAME = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH)
Settings.FILE_PATH = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH)
Settings.SUB_ORG_PATTERN = new Glob(`${CONFIG_PATH}/suborgs/*.yml`)
@@ -1502,3 +2131,7 @@ Settings.PLUGINS = {
}
module.exports = Settings
+module.exports.isEmptyChange = isEmptyChange
+module.exports.isDeepEmpty = isDeepEmpty
+module.exports.getChangedEntryNames = getChangedEntryNames
+module.exports.filterActionByChangedNames = filterActionByChangedNames
diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json
index b30e03902..0899fd06e 100644
--- a/schema/dereferenced/settings.json
+++ b/schema/dereferenced/settings.json
@@ -448,12 +448,7 @@
},
"permission": {
"type": "string",
- "description": "**Closing down notice**. The permission that new repositories will be added to the team with when none is specified.",
- "enum": [
- "pull",
- "push"
- ],
- "default": "pull"
+ "description": "The permission to grant the team on this repository. We accept the following permissions to be set: `pull`, `triage`, `push`, `maintain`, `admin` and you can also specify a custom repository role name, if the owning organization has defined any."
},
"parent_team_id": {
"type": "integer",
From 1892ac7c777e844455a2293b23207beef6cbb926 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 12 Jun 2026 21:01:50 -0400
Subject: [PATCH 20/67] Add reverse settings generator (issue #994)
Generate safe-settings YAML from existing GitHub configuration for a repo,
org, or custom-property-based suborg.
- lib/settingsGenerator.js: extraction engine reusing each plugin's find()
to read current state and produce config/YAML, with cross-repo
intersection for suborg generation.
- generate-settings.js: standalone CLI that writes generated YAML to the
local filesystem (.sample.yml unless --overwrite); loads .env manually.
- index.js + app.yml: repository_dispatch (safe-settings-generate) handler
that always opens a PR against the admin repo (never commits to the
default branch directly).
- Suborg files are named suborgs/_.yml.
- README: document generator usage and the PR-only guarantee.
- Unit tests for the generator (25 tests).
---
README.md | 116 ++++++
app.yml | 1 +
generate-settings.js | 156 ++++++++
index.js | 135 ++++++-
lib/settingsGenerator.js | 498 ++++++++++++++++++++++++
test/unit/lib/settingsGenerator.test.js | 263 +++++++++++++
6 files changed, 1168 insertions(+), 1 deletion(-)
create mode 100644 generate-settings.js
create mode 100644 lib/settingsGenerator.js
create mode 100644 test/unit/lib/settingsGenerator.test.js
diff --git a/README.md b/README.md
index 969b31c2f..807fe7d22 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,8 @@ The App listens to the following webhook events:
- __custom_property_values__: If new repository properties are set for a repository, `safe-settings` will run to so that if a sub-org config is defined by that property, it will be applied for the repo
+- **repository_dispatch** (`event_type: safe-settings-generate`): Triggers the **settings generator**, which reads the current configuration of a repo/org/suborg and opens a PR against the `admin` repo with the generated YAML. See [Generating settings from existing configuration](#generating-settings-from-existing-configuration).
+
### Dry-run PR comment
When a config change is proposed in a PR (a non-default branch), `safe-settings` runs in `nop` (no-operation) `dry-run` mode and posts a comment summarizing what *would* change if the PR were merged. The results are filtered against the **base branch** config, so the comment reports only the changes the PR introduces — not the full diff against live GitHub settings.
@@ -824,6 +826,120 @@ The script uses colored terminal output with pass (✅) / fail (❌) indicators
```
+## Generating settings from existing configuration
+
+Safe-settings normally works "forward": you declare settings in YAML and it applies them to GitHub. The **settings generator** does the reverse — it reads the *current* state of a repo, an org, or a collection of repos (a suborg) and produces the corresponding safe-settings YAML (`repos/.yml`, `settings.yml`, or `suborgs/.yml`). This is useful for onboarding existing repositories/orgs onto safe-settings without hand-authoring config.
+
+It can be invoked two ways:
+
+- **Standalone CLI** (`generate-settings.js`) — writes the generated file to your local filesystem.
+- **App trigger** via a `repository_dispatch` event — the running app generates the file and opens a **pull request** against the admin repo.
+
+### Source types
+
+| `source_type` | `source_value` | What is extracted | Output file |
+|---|---|---|---|
+| `repo` | repository name | All repo-level plugins (repository, labels, collaborators, teams, milestones, branches, autolinks, custom_properties, variables, environments, repo rulesets) | `repos/.yml` |
+| `org` | org login | Org-level rulesets and custom repository roles only | `settings.yml` |
+| `custom-property` | `name=value` (e.g. `Team=backend`) | Repo-level settings **common to all repos** carrying that custom property value (intersection) | `suborgs/_.yml` |
+
+> **Note on suborgs:** for `custom-property`, the generator discovers every repo with the given custom property value, extracts each repo's config, and keeps only the settings that are **identical across all of them**. A `suborgproperties` selector is prepended automatically.
+
+### Overwrite behavior
+
+By default (`overwrite=false`) the generator will **not** replace an existing file. If the target already exists it writes a `.sample.yml` file next to it instead. Set `overwrite=true` to replace the file.
+
+### Standalone invocation
+
+The CLI loads variables from a `.env` file in the project root (`APP_ID`, `PRIVATE_KEY`, and optionally `GH_ORG`/`OWNER`). Options can be passed as flags or environment variables.
+
+```bash
+# Generate repos/my-repo.yml from a single repository
+node generate-settings.js \
+ --source-type repo \
+ --source-value my-repo \
+ --owner my-org \
+ --output-dir ./out
+
+# Generate settings.yml from org-level rulesets + custom repository roles
+node generate-settings.js --source-type org --source-value my-org --output-dir ./out
+
+# Generate suborgs/Team_backend.yml from all repos with the custom property Team=backend
+node generate-settings.js \
+ --source-type custom-property \
+ --source-value "Team=backend" \
+ --owner my-org \
+ --output-dir ./out
+
+# Overwrite an existing file instead of writing a .sample.yml
+node generate-settings.js --source-type repo --source-value my-repo --owner my-org --overwrite
+
+# Using environment variables instead of flags
+SOURCE_TYPE=repo SOURCE_VALUE=my-repo OWNER=my-org OUTPUT_DIR=./out node generate-settings.js
+```
+
+| Flag | Env var | Description | Default |
+|---|---|---|---|
+| `--source-type` | `SOURCE_TYPE` | `repo`, `org`, or `custom-property` | (required) |
+| `--source-value` | `SOURCE_VALUE` | repo name / org login / `name=value` | (required) |
+| `--property-name` | `SOURCE_PROPERTY_NAME` | Custom property name (alternative to encoding it in `--source-value`) | — |
+| `--owner` | `OWNER` / `GITHUB_ORG` / `GH_ORG` | Org login (selects the matching App installation) | first installation |
+| `--output-dir` | `OUTPUT_DIR` | Directory to write generated files into | `.` |
+| `--overwrite` | `OVERWRITE=true` | Replace existing files instead of writing `.sample.yml` | `false` |
+
+### App invocation (`repository_dispatch`)
+
+When the app is running, trigger generation by sending a `repository_dispatch` event (with `event_type: safe-settings-generate`) to the **admin repo**. The app generates the file and opens a PR against the admin repo's default branch.
+
+```bash
+# Generate a repo config and open a PR
+gh api --method POST \
+ /repos/my-org/admin/dispatches \
+ -f event_type=safe-settings-generate \
+ -F 'client_payload[source_type]=repo' \
+ -F 'client_payload[source_value]=my-repo' \
+ -F 'client_payload[overwrite]=false'
+
+# Generate org-level settings.yml and open a PR
+gh api --method POST \
+ /repos/my-org/admin/dispatches \
+ -f event_type=safe-settings-generate \
+ -F 'client_payload[source_type]=org' \
+ -F 'client_payload[source_value]=my-org'
+
+# Generate a suborg config from a custom property
+gh api --method POST \
+ /repos/my-org/admin/dispatches \
+ -f event_type=safe-settings-generate \
+ -F 'client_payload[source_type]=custom-property' \
+ -F 'client_payload[source_value]=Team=backend' \
+ -F 'client_payload[overwrite]=false'
+```
+
+The `client_payload` fields are:
+
+| Field | Description | Required |
+|---|---|---|
+| `source_type` | `repo`, `org`, or `custom-property` | Yes |
+| `source_value` | repo name / org login / `name=value` | Yes |
+| `property_name` | Custom property name (alternative to encoding it in `source_value`) | No |
+| `overwrite` | `true` to replace an existing file; otherwise a `.sample.yml` is created | No (default `false`) |
+
+> **Tip:** Always review the generated PR before merging. Running safe-settings in NOP mode against the generated config should report no unexpected diffs.
+
+#### Generated changes always go through a pull request
+
+The app **never** commits generated configuration directly to the admin repo's default branch. Every `repository_dispatch` invocation produces a pull request that must be reviewed and merged before it takes effect. Concretely, for each request the app:
+
+1. Creates a **new branch** off the admin repo's default branch (`safe-settings-generate/--`).
+2. Commits the generated YAML **to that branch only**.
+3. Opens a **pull request** from that branch against the default branch.
+
+This means it is safe to give developers write access to the admin repo so they can trigger generation: a `repository_dispatch` event can only create a branch and open a PR — it cannot change the live configuration on its own. The generated config does not reach the path safe-settings acts on until the PR is merged, so all changes are subject to your normal review process and any branch protection / required-reviews rules configured on the admin repo's default branch.
+
+To enforce review, protect the admin repo's default branch (for example, require pull request reviews and disallow direct pushes). Because the generator only ever writes to a feature branch and opens a PR, those rules apply to every generated change.
+
+
## License
`safe-settings` is licensed under the [ISC license](https://github.com/github/safe-settings/blob/master/LICENSE)
diff --git a/app.yml b/app.yml
index 2f053bbf0..e61ffdf30 100644
--- a/app.yml
+++ b/app.yml
@@ -22,6 +22,7 @@ default_events:
- pull_request
- push
- repository
+ - repository_dispatch
- repository_ruleset
- team
diff --git a/generate-settings.js b/generate-settings.js
new file mode 100644
index 000000000..ab752fbaf
--- /dev/null
+++ b/generate-settings.js
@@ -0,0 +1,156 @@
+/* eslint-disable camelcase */
+/**
+ * Standalone CLI to generate safe-settings YAML from the *current* state of a
+ * repo / org / collection-of-repos and write it to the local filesystem.
+ *
+ * Usage (env or flags):
+ * SOURCE_TYPE=repo SOURCE_VALUE=my-repo node generate-settings.js
+ * SOURCE_TYPE=org SOURCE_VALUE=my-org node generate-settings.js
+ * SOURCE_TYPE=custom-property SOURCE_VALUE=Team=backend node generate-settings.js
+ *
+ * node generate-settings.js --source-type repo --source-value my-repo \
+ * --owner my-org --output-dir ./out --overwrite
+ *
+ * When overwrite is false (default) and the target file already exists, a
+ * `.sample.yml` file is written instead of replacing the existing file.
+ */
+const fs = require('fs')
+const path = require('path')
+
+// Load .env into process.env before any module reads it (lib/env.js reads at
+// require time). Mirrors the lightweight parser used by smoke-test.js so we
+// avoid adding a dotenv dependency.
+function loadEnv () {
+ const envPath = path.join(__dirname, '.env')
+ if (!fs.existsSync(envPath)) return
+ const lines = fs.readFileSync(envPath, 'utf8').split('\n')
+ let currentKey = null
+ let currentValue = ''
+ let inMultiline = false
+
+ for (const line of lines) {
+ if (inMultiline) {
+ currentValue += '\n' + line
+ if (line.includes('"') || line.includes("'")) {
+ const val = currentValue.replace(/^["']|["']$/g, '')
+ // Like dotenv: .env values don't override existing env vars
+ if (!(currentKey in process.env)) process.env[currentKey] = val
+ inMultiline = false
+ }
+ continue
+ }
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const eqIdx = trimmed.indexOf('=')
+ if (eqIdx === -1) continue
+ currentKey = trimmed.slice(0, eqIdx).trim()
+ currentValue = trimmed.slice(eqIdx + 1).trim()
+ if ((currentValue.startsWith('"') && !currentValue.endsWith('"')) ||
+ (currentValue.startsWith("'") && !currentValue.endsWith("'"))) {
+ inMultiline = true
+ continue
+ }
+ const val = currentValue.replace(/^["']|["']$/g, '')
+ if (!(currentKey in process.env)) process.env[currentKey] = val
+ }
+}
+
+loadEnv()
+const { createProbot } = require('probot')
+const SettingsGenerator = require('./lib/settingsGenerator')
+
+function parseArgs (argv) {
+ const args = {}
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i]
+ if (arg.startsWith('--')) {
+ const key = arg.slice(2)
+ if (key === 'overwrite') {
+ args.overwrite = true
+ } else {
+ args[key] = argv[++i]
+ }
+ }
+ }
+ return args
+}
+
+function resolveOptions () {
+ const args = parseArgs(process.argv.slice(2))
+ const sourceType = args['source-type'] || process.env.SOURCE_TYPE
+ const sourceValue = args['source-value'] || process.env.SOURCE_VALUE
+ const propertyName = args['property-name'] || process.env.SOURCE_PROPERTY_NAME
+ const owner = args.owner || process.env.OWNER || process.env.GITHUB_ORG || process.env.GH_ORG
+ const outputDir = args['output-dir'] || process.env.OUTPUT_DIR || '.'
+ const overwrite = args.overwrite || process.env.OVERWRITE === 'true'
+
+ if (!sourceType || !sourceValue) {
+ throw new Error('SOURCE_TYPE and SOURCE_VALUE (or --source-type/--source-value) are required')
+ }
+ return { sourceType, sourceValue, propertyName, owner, outputDir, overwrite }
+}
+
+/**
+ * Get an authenticated installation octokit + the org login.
+ * If OWNER is provided we match its installation, otherwise use the first.
+ */
+async function getInstallationClient (probot, owner) {
+ const app = await probot.auth()
+ const installations = await app.paginate(
+ app.apps.listInstallations.endpoint.merge({ per_page: 100 })
+ )
+ if (installations.length === 0) {
+ throw new Error('No installations found for this GitHub App')
+ }
+ const installation = owner
+ ? installations.find(i => i.account.login.toLowerCase() === owner.toLowerCase())
+ : installations[0]
+ if (!installation) {
+ throw new Error(`No installation found for owner "${owner}"`)
+ }
+ const github = await probot.auth(installation.id)
+ return { github, owner: installation.account.login }
+}
+
+/**
+ * Write content to disk honoring the overwrite/.sample rule.
+ * @returns {string} the path actually written
+ */
+function writeOutput (outputDir, filePath, content, overwrite) {
+ let target = path.join(outputDir, filePath)
+ if (!overwrite && fs.existsSync(target)) {
+ const parsed = path.parse(target)
+ target = path.join(parsed.dir, `${parsed.name}.sample${parsed.ext}`)
+ }
+ fs.mkdirSync(path.dirname(target), { recursive: true })
+ fs.writeFileSync(target, content)
+ return target
+}
+
+async function main () {
+ const opts = resolveOptions()
+ const probot = createProbot()
+ probot.log.info(`Generating settings: source-type=${opts.sourceType} source-value=${opts.sourceValue}`)
+
+ const { github, owner } = await getInstallationClient(probot, opts.owner)
+ const generator = new SettingsGenerator(github, owner, { log: probot.log })
+
+ const { filePath, yaml } = await generator.generate({
+ sourceType: opts.sourceType,
+ sourceValue: opts.sourceValue,
+ propertyName: opts.propertyName
+ })
+
+ const written = writeOutput(opts.outputDir, filePath, yaml, opts.overwrite)
+ probot.log.info(`Wrote ${written}`)
+ process.stdout.write(`${written}\n`)
+}
+
+if (require.main === module) {
+ main().catch(error => {
+ process.stderr.write(`Error generating settings: ${error.stack || error}\n`)
+ process.exit(1)
+ })
+}
+
+module.exports = { parseArgs, resolveOptions, writeOutput, getInstallationClient }
diff --git a/index.js b/index.js
index 8cfdf0610..860d0474a 100644
--- a/index.js
+++ b/index.js
@@ -5,6 +5,7 @@ const cron = require('node-cron')
const Glob = require('./lib/glob')
const ConfigManager = require('./lib/configManager')
const NopCommand = require('./lib/nopcommand')
+const SettingsGenerator = require('./lib/settingsGenerator')
const env = require('./lib/env')
let deploymentConfig
@@ -667,6 +668,137 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return syncSettings(false, context)
})
+ /**
+ * Generate safe-settings YAML from the current state of a repo / org /
+ * collection-of-repos and open a PR against the admin repo with the result.
+ *
+ * @param {import('probot').Context} context
+ * @param {object} opts
+ * @param {'repo'|'org'|'custom-property'} opts.sourceType
+ * @param {string} opts.sourceValue
+ * @param {string} [opts.propertyName]
+ * @param {boolean} [opts.overwrite]
+ */
+ async function generateSettings (context, opts) {
+ const owner = context.repo().owner
+ const github = context.octokit
+ const generator = new SettingsGenerator(github, owner, { log: robot.log })
+
+ const { filePath, yaml: content } = await generator.generate({
+ sourceType: opts.sourceType,
+ sourceValue: opts.sourceValue,
+ propertyName: opts.propertyName
+ })
+
+ const targetPath = await resolveOutputPath(context, filePath, opts.overwrite)
+ return openSettingsPR(context, targetPath, content, opts)
+ }
+
+ /**
+ * Honor the overwrite/.sample rule against the admin repo: if overwrite is
+ * false and the file already exists on the default branch, target a
+ * `.sample.yml` path instead.
+ */
+ async function resolveOutputPath (context, filePath, overwrite) {
+ if (overwrite) return filePath
+ const { owner } = context.repo()
+ try {
+ await context.octokit.repos.getContent({ owner, repo: env.ADMIN_REPO, path: filePath })
+ // File exists -> redirect to .sample
+ return filePath.replace(/(\.ya?ml)$/i, '.sample$1')
+ } catch (e) {
+ if (e.status === 404) return filePath
+ throw e
+ }
+ }
+
+ /**
+ * Create a branch on the admin repo, commit the generated file, and open a PR.
+ */
+ async function openSettingsPR (context, filePath, content, opts) {
+ const github = context.octokit
+ const { owner } = context.repo()
+ const repo = env.ADMIN_REPO
+
+ const repoInfo = await github.repos.get({ owner, repo })
+ const baseBranch = repoInfo.data.default_branch
+ const baseRef = await github.git.getRef({ owner, repo, ref: `heads/${baseBranch}` })
+ const branchName = `safe-settings-generate/${opts.sourceType}-${opts.sourceValue}-${Date.now()}`.replace(/[^a-zA-Z0-9/_.-]/g, '-')
+
+ await github.git.createRef({
+ owner,
+ repo,
+ ref: `refs/heads/${branchName}`,
+ sha: baseRef.data.object.sha
+ })
+
+ let existingSha
+ try {
+ const existing = await github.repos.getContent({ owner, repo, path: filePath, ref: branchName })
+ existingSha = existing.data.sha
+ } catch (e) {
+ if (e.status !== 404) throw e
+ }
+
+ await github.repos.createOrUpdateFileContents({
+ owner,
+ repo,
+ path: filePath,
+ branch: branchName,
+ message: `Generate ${filePath} from current ${opts.sourceType} settings`,
+ content: Buffer.from(content).toString('base64'),
+ sha: existingSha
+ })
+
+ const pr = await github.pulls.create({
+ owner,
+ repo,
+ title: `Generate safe-settings config for ${opts.sourceType}: ${opts.sourceValue}`,
+ head: branchName,
+ base: baseBranch,
+ body: [
+ `Auto-generated safe-settings configuration from the current state of \`${opts.sourceType}\` \`${opts.sourceValue}\`.`,
+ '',
+ `- File: \`${filePath}\``,
+ `- Overwrite: \`${!!opts.overwrite}\``,
+ '',
+ 'Review carefully before merging. Run in nop mode to confirm there are no unexpected diffs.'
+ ].join('\n')
+ })
+
+ robot.log.info(`Opened settings-generation PR #${pr.data.number} (${filePath})`)
+ return pr.data
+ }
+
+ // Trigger generation via a repository_dispatch event:
+ // event_type: safe-settings-generate
+ // client_payload: { source_type, source_value, overwrite, property_name? }
+ robot.on('repository_dispatch', async context => {
+ const { payload } = context
+ if (payload.action !== 'safe-settings-generate') {
+ robot.log.debug(`Ignoring repository_dispatch action "${payload.action}"`)
+ return
+ }
+ const cp = payload.client_payload || {}
+ const sourceType = cp.source_type
+ const sourceValue = cp.source_value
+ if (!sourceType || !sourceValue) {
+ robot.log.error('repository_dispatch safe-settings-generate requires source_type and source_value')
+ return
+ }
+ try {
+ return await generateSettings(context, {
+ sourceType,
+ sourceValue,
+ propertyName: cp.property_name,
+ overwrite: cp.overwrite === true || cp.overwrite === 'true'
+ })
+ } catch (e) {
+ robot.log.error(`Failed to generate settings: ${e.stack || e}`)
+ throw e
+ }
+ })
+
if (process.env.CRON) {
/*
# ┌────────────── second (optional)
@@ -689,6 +821,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
info()
return {
- syncInstallation
+ syncInstallation,
+ generateSettings
}
}
diff --git a/lib/settingsGenerator.js b/lib/settingsGenerator.js
new file mode 100644
index 000000000..64e30d663
--- /dev/null
+++ b/lib/settingsGenerator.js
@@ -0,0 +1,498 @@
+/* eslint-disable camelcase */
+const yaml = require('js-yaml')
+const Settings = require('./settings')
+const MergeDeep = require('./mergeDeep')
+const env = require('./env')
+
+/**
+ * SettingsGenerator
+ *
+ * The reverse of safe-settings: read the *current* configuration of a repo /
+ * org / collection-of-repos from the GitHub API and emit safe-settings YAML
+ * (`repos/.yml`, `settings.yml`, `suborgs/.yml`).
+ *
+ * The heavy lifting (knowing which API to call to read current state) already
+ * lives in each plugin's `find()` method, so wherever possible we instantiate
+ * the existing plugin in nop mode with empty entries and reuse its `find()`.
+ * The raw API shape is then reduced to the configurable subset that the
+ * safe-settings schema understands.
+ */
+
+// Keys that are pure API noise and should never appear in generated config.
+const NOISE_KEYS = new Set([
+ 'id', 'node_id', 'url', 'html_url', 'repository_url', 'labels_url', 'events_url',
+ 'created_at', 'updated_at', 'pushed_at', 'creator', '_links', 'current_user_can_bypass'
+])
+
+function makeLogger () {
+ const noop = () => {}
+ const logger = { debug: noop, info: noop, warn: noop, error: noop, trace: noop }
+ logger.child = () => logger
+ return logger
+}
+
+/**
+ * Recursively strip API-only noise keys from an arbitrary value.
+ * Used for sections (rulesets, environments) whose API shape is large and
+ * not worth hand-mapping field by field.
+ */
+function stripNoise (value) {
+ if (Array.isArray(value)) {
+ return value.map(stripNoise)
+ }
+ if (value && typeof value === 'object') {
+ const out = {}
+ for (const [k, v] of Object.entries(value)) {
+ if (NOISE_KEYS.has(k)) continue
+ if (v === null || v === undefined) continue
+ out[k] = stripNoise(v)
+ }
+ return out
+ }
+ return value
+}
+
+/** Remove sections whose value is empty (undefined, [], {} ). */
+function pruneEmpty (config) {
+ const out = {}
+ for (const [section, value] of Object.entries(config)) {
+ if (value === undefined || value === null) continue
+ if (Array.isArray(value) && value.length === 0) continue
+ if (!Array.isArray(value) && typeof value === 'object' && Object.keys(value).length === 0) continue
+ out[section] = value
+ }
+ return out
+}
+
+class SettingsGenerator {
+ /**
+ * @param {object} github An authenticated octokit instance.
+ * @param {string} owner The org / owner login.
+ * @param {object} [opts]
+ * @param {object} [opts.log] Logger; defaults to a silent logger.
+ */
+ constructor (github, owner, opts = {}) {
+ this.github = github
+ this.owner = owner
+ this.log = opts.log || makeLogger()
+ this.errors = []
+ }
+
+ /**
+ * Instantiate a Diffable plugin in nop mode with empty entries and return
+ * its `find()` result (the current state read from GitHub).
+ *
+ * @param {string} section Plugin/section name (key of Settings.PLUGINS).
+ * @param {object} repo { owner, repo }
+ * @param {string} [scope] Optional scope passed to plugins that accept it
+ * (rulesets uses 'org' | 'repo').
+ */
+ async findExisting (section, repo, scope) {
+ const Plugin = Settings.PLUGINS[section]
+ if (!Plugin) throw new Error(`Unknown plugin section: ${section}`)
+ const instance = new Plugin(true, this.github, repo, [], this.log, this.errors, scope)
+ return instance.find()
+ }
+
+ // --- Section extractors -------------------------------------------------
+
+ async repository (repo) {
+ const { data } = await this.github.repos.get(repo)
+ const fields = [
+ 'name', 'description', 'homepage', 'private', 'visibility',
+ 'has_issues', 'has_projects', 'has_wiki', 'has_downloads', 'is_template',
+ 'default_branch', 'allow_squash_merge', 'allow_merge_commit',
+ 'allow_rebase_merge', 'allow_auto_merge', 'delete_branch_on_merge',
+ 'allow_update_branch', 'squash_merge_commit_title', 'squash_merge_commit_message',
+ 'merge_commit_title', 'merge_commit_message', 'web_commit_signoff_required',
+ 'archived'
+ ]
+ const out = {}
+ for (const f of fields) {
+ if (data[f] !== undefined && data[f] !== null) out[f] = data[f]
+ }
+ if (Array.isArray(data.topics) && data.topics.length > 0) out.topics = data.topics
+ return out
+ }
+
+ async labels (repo) {
+ const existing = await this.findExisting('labels', repo)
+ return (existing || []).map(({ name, color, description }) => ({
+ name,
+ color: color ? String(color) : undefined,
+ description: description || undefined
+ }))
+ }
+
+ async collaborators (repo) {
+ const existing = await this.findExisting('collaborators', repo)
+ return (existing || [])
+ .filter(c => c && c.username)
+ .map(({ username, permission }) => ({ username, permission }))
+ }
+
+ async teams (repo) {
+ const existing = await this.findExisting('teams', repo)
+ return (existing || []).map(t => ({
+ name: t.slug || t.name,
+ permission: t.permission
+ }))
+ }
+
+ async milestones (repo) {
+ const existing = await this.findExisting('milestones', repo)
+ return (existing || []).map(({ title, description, state }) => ({
+ title,
+ description: description || undefined,
+ state: state || undefined
+ }))
+ }
+
+ async autolinks (repo) {
+ const existing = await this.findExisting('autolinks', repo)
+ return (existing || []).map(({ key_prefix, url_template, is_alphanumeric }) => ({
+ key_prefix,
+ url_template,
+ is_alphanumeric
+ }))
+ }
+
+ async custom_properties (repo) {
+ const existing = await this.findExisting('custom_properties', repo)
+ return (existing || []).filter(p => p && p.value !== null && p.value !== undefined)
+ }
+
+ async variables (repo) {
+ const existing = await this.findExisting('variables', repo)
+ return (existing || []).map(({ name, value }) => ({ name, value }))
+ }
+
+ async environments (repo) {
+ const existing = await this.findExisting('environments', repo)
+ return stripNoise(existing || [])
+ }
+
+ async rulesets (repo, scope = 'repo') {
+ const existing = await this.findExisting('rulesets', repo, scope)
+ return (existing || []).map(rs => {
+ const { source, source_type, ...rest } = stripNoise(rs)
+ return rest
+ })
+ }
+
+ async custom_repository_roles (repo) {
+ const existing = await this.findExisting('custom_repository_roles', repo)
+ return (existing || []).map(({ id, ...rest }) => rest)
+ }
+
+ async branches (repo) {
+ let branchList
+ try {
+ branchList = await this.github.paginate(this.github.repos.listBranches, {
+ owner: repo.owner,
+ repo: repo.repo,
+ protected: true,
+ per_page: 100
+ })
+ } catch (e) {
+ this.log.debug(`Could not list protected branches for ${repo.repo}: ${e.message}`)
+ return []
+ }
+
+ const result = []
+ for (const b of branchList || []) {
+ try {
+ const { data } = await this.github.repos.getBranchProtection({
+ owner: repo.owner,
+ repo: repo.repo,
+ branch: b.name
+ })
+ result.push({ name: b.name, protection: this.reformatBranchProtection(data) })
+ } catch (e) {
+ this.log.debug(`Could not read branch protection for ${repo.repo}#${b.name}: ${e.message}`)
+ }
+ }
+ return result
+ }
+
+ /**
+ * Convert the GitHub branch-protection API response into the flatter shape
+ * used by safe-settings config (boolean toggles instead of `{ enabled }`).
+ * Mirrors Branches.reformatAndReturnBranchProtection.
+ */
+ reformatBranchProtection (protection) {
+ if (!protection) return protection
+ const p = stripNoise(protection)
+ const flatten = key => {
+ if (p[key] && typeof p[key] === 'object' && 'enabled' in p[key]) {
+ p[key] = p[key].enabled
+ }
+ }
+ flatten('required_conversation_resolution')
+ flatten('allow_deletions')
+ flatten('required_linear_history')
+ flatten('enforce_admins')
+ flatten('required_signatures')
+ flatten('allow_force_pushes')
+ flatten('block_creations')
+ flatten('lock_branch')
+ return p
+ }
+
+ // --- Scope builders -----------------------------------------------------
+
+ /**
+ * Build the full repo-level config object for a single repository.
+ * @param {string} repoName
+ * @returns {Promise} pruned config (empty sections removed)
+ */
+ async buildRepoConfig (repoName) {
+ const repo = { owner: this.owner, repo: repoName }
+ const sections = [
+ 'repository', 'labels', 'collaborators', 'teams', 'milestones',
+ 'branches', 'autolinks', 'custom_properties', 'variables',
+ 'environments'
+ ]
+ const config = {}
+ for (const section of sections) {
+ try {
+ config[section] = await this[section](repo)
+ } catch (e) {
+ this.log.warn(`Failed to extract ${section} for ${repoName}: ${e.message}`)
+ }
+ }
+ try {
+ config.rulesets = await this.rulesets(repo, 'repo')
+ } catch (e) {
+ this.log.warn(`Failed to extract rulesets for ${repoName}: ${e.message}`)
+ }
+ return pruneEmpty(config)
+ }
+
+ /**
+ * Build the org-level (settings.yml) config. At org scope we can only read
+ * org-level rulesets and custom repository roles.
+ * @returns {Promise}
+ */
+ async buildOrgConfig () {
+ const repo = { owner: this.owner, repo: env.ADMIN_REPO }
+ const config = {}
+ try {
+ config.rulesets = await this.rulesets(repo, 'org')
+ } catch (e) {
+ this.log.warn(`Failed to extract org rulesets: ${e.message}`)
+ }
+ try {
+ config.custom_repository_roles = await this.custom_repository_roles(repo)
+ } catch (e) {
+ this.log.warn(`Failed to extract custom repository roles: ${e.message}`)
+ }
+ return pruneEmpty(config)
+ }
+
+ /**
+ * Build a suborg config for all repos that carry a custom property value.
+ * Settings common to ALL matching repos are kept (intersection).
+ * @param {string} propertyName
+ * @param {string|boolean} propertyValue
+ * @returns {Promise}
+ */
+ async buildSubOrgConfig (propertyName, propertyValue) {
+ const repos = await this.findReposByProperty(propertyName, propertyValue)
+ if (repos.length === 0) {
+ return { suborgproperties: [{ [propertyName]: propertyValue }] }
+ }
+
+ const configs = []
+ for (const repoName of repos) {
+ configs.push(await this.buildRepoConfig(repoName))
+ }
+
+ const common = intersectConfigs(configs)
+ return Object.assign(
+ { suborgproperties: [{ [propertyName]: propertyValue }] },
+ pruneEmpty(common)
+ )
+ }
+
+ /**
+ * High level entry point. Resolve the target file path and config content
+ * for a given source descriptor.
+ *
+ * @param {object} source
+ * @param {'repo'|'org'|'custom-property'} source.sourceType
+ * @param {string} source.sourceValue For 'repo' the repo name; for 'org' the
+ * org login; for 'custom-property' a `name=value` pair (or just the value
+ * if `propertyName` is supplied separately).
+ * @param {string} [source.propertyName] Custom property name (alternative to
+ * encoding it in sourceValue).
+ * @returns {Promise<{ filePath: string, config: object, yaml: string }>}
+ */
+ async generate ({ sourceType, sourceValue, propertyName } = {}) {
+ let config
+ let filePath
+ const base = env.CONFIG_PATH
+
+ switch (sourceType) {
+ case 'repo': {
+ config = await this.buildRepoConfig(sourceValue)
+ filePath = `${base}/repos/${sourceValue}.yml`
+ break
+ }
+ case 'org': {
+ config = await this.buildOrgConfig()
+ filePath = `${base}/${env.SETTINGS_FILE_PATH}`
+ break
+ }
+ case 'custom-property':
+ case 'custom-property-name': {
+ const { name, value } = parsePropertyValue(sourceValue, propertyName)
+ config = await this.buildSubOrgConfig(name, value)
+ filePath = `${base}/suborgs/${name}_${value}.yml`
+ break
+ }
+ default:
+ throw new Error(`Unsupported source type: ${sourceType}`)
+ }
+
+ return { filePath, config, yaml: toYaml(config) }
+ }
+
+ /**
+ * Discover repository names that have a given custom property value.
+ * Mirrors Settings.getRepositoriesByProperty.
+ * @returns {Promise}
+ */
+ async findReposByProperty (propertyName, propertyValue) {
+ const query = `props.${propertyName}:${propertyValue}`
+ const encodedQuery = encodeURIComponent(query)
+ const options = this.github.request.endpoint(
+ `/orgs/${this.owner}/properties/values?repository_query=${encodedQuery}`
+ )
+ const results = await this.github.paginate(options)
+ return (results || [])
+ .map(r => r.repository_name)
+ .filter(Boolean)
+ }
+}
+
+// --- Intersection helpers -------------------------------------------------
+const NAME_FIELDS = (MergeDeep.NAME_FIELDS || [])
+ .concat(['title'])
+
+function identityOf (item) {
+ if (!item || typeof item !== 'object') return undefined
+ const prop = NAME_FIELDS.find(p => Object.prototype.hasOwnProperty.call(item, p))
+ return prop ? `${prop}:${item[prop]}` : undefined
+}
+
+function deepEqual (a, b) {
+ if (a === b) return true
+ if (typeof a !== typeof b) return false
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) return false
+ return a.every((x, i) => deepEqual(x, b[i]))
+ }
+ if (a && b && typeof a === 'object') {
+ const ak = Object.keys(a)
+ const bk = Object.keys(b)
+ if (ak.length !== bk.length) return false
+ return ak.every(k => deepEqual(a[k], b[k]))
+ }
+ return false
+}
+
+/**
+ * Reduce a list of config objects to the parts that are identical across ALL
+ * of them.
+ * - scalars: kept only if equal everywhere
+ * - arrays: items kept only if an item with the same identity (NAME_FIELDS)
+ * AND deep-equal value is present in every config
+ * - objects: recursively intersected
+ * @param {object[]} configs
+ * @returns {object}
+ */
+function intersectConfigs (configs) {
+ if (!configs || configs.length === 0) return {}
+ if (configs.length === 1) return configs[0]
+
+ const result = {}
+ // Only consider sections present in every config.
+ const commonSections = Object.keys(configs[0]).filter(section =>
+ configs.every(c => Object.prototype.hasOwnProperty.call(c, section))
+ )
+
+ for (const section of commonSections) {
+ const values = configs.map(c => c[section])
+ result[section] = intersectValues(values)
+ }
+ return result
+}
+
+function intersectValues (values) {
+ const [first] = values
+
+ if (Array.isArray(first)) {
+ if (!values.every(Array.isArray)) return undefined
+ const kept = []
+ for (const item of first) {
+ const id = identityOf(item)
+ const presentEverywhere = values.every(arr =>
+ arr.some(other => (id !== undefined
+ ? identityOf(other) === id && deepEqual(other, item)
+ : deepEqual(other, item)))
+ )
+ if (presentEverywhere) kept.push(item)
+ }
+ return kept
+ }
+
+ if (first && typeof first === 'object') {
+ if (!values.every(v => v && typeof v === 'object' && !Array.isArray(v))) return undefined
+ const out = {}
+ const commonKeys = Object.keys(first).filter(k =>
+ values.every(v => Object.prototype.hasOwnProperty.call(v, k))
+ )
+ for (const k of commonKeys) {
+ const intersected = intersectValues(values.map(v => v[k]))
+ if (intersected !== undefined) out[k] = intersected
+ }
+ return out
+ }
+
+ // scalar
+ return values.every(v => deepEqual(v, first)) ? first : undefined
+}
+
+/** Serialize a config object to YAML. */
+function toYaml (config) {
+ return yaml.dump(config, { lineWidth: -1, noRefs: true })
+}
+
+/**
+ * Parse a custom-property source value. Accepts either a `name=value` pair, a
+ * `name:value` pair, or just a value when `propertyName` is supplied.
+ * @returns {{ name: string, value: string }}
+ */
+function parsePropertyValue (sourceValue, propertyName) {
+ if (propertyName) {
+ return { name: propertyName, value: sourceValue }
+ }
+ const match = /^([^=:]+)[=:](.+)$/.exec(String(sourceValue || ''))
+ if (!match) {
+ throw new Error(
+ `custom-property source requires a "name=value" pair (got "${sourceValue}")`
+ )
+ }
+ return { name: match[1].trim(), value: match[2].trim() }
+}
+
+module.exports = SettingsGenerator
+module.exports.SettingsGenerator = SettingsGenerator
+module.exports.intersectConfigs = intersectConfigs
+module.exports.intersectValues = intersectValues
+module.exports.deepEqual = deepEqual
+module.exports.stripNoise = stripNoise
+module.exports.pruneEmpty = pruneEmpty
+module.exports.toYaml = toYaml
+module.exports.parsePropertyValue = parsePropertyValue
diff --git a/test/unit/lib/settingsGenerator.test.js b/test/unit/lib/settingsGenerator.test.js
new file mode 100644
index 000000000..6feef5c34
--- /dev/null
+++ b/test/unit/lib/settingsGenerator.test.js
@@ -0,0 +1,263 @@
+/* eslint-disable no-undef */
+const SettingsGenerator = require('../../../lib/settingsGenerator')
+const {
+ intersectConfigs,
+ deepEqual,
+ pruneEmpty,
+ stripNoise,
+ parsePropertyValue,
+ toYaml
+} = SettingsGenerator
+
+const silentLog = { debug () {}, info () {}, warn () {}, error () {}, trace () {}, child () { return this } }
+
+function makeGenerator (github = {}) {
+ return new SettingsGenerator(github, 'my-org', { log: silentLog })
+}
+
+describe('SettingsGenerator helpers', () => {
+ describe('deepEqual', () => {
+ it('compares scalars, arrays and objects', () => {
+ expect(deepEqual(1, 1)).toBe(true)
+ expect(deepEqual('a', 'b')).toBe(false)
+ expect(deepEqual([1, 2], [1, 2])).toBe(true)
+ expect(deepEqual([1, 2], [2, 1])).toBe(false)
+ expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true)
+ expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
+ })
+ })
+
+ describe('stripNoise', () => {
+ it('removes API-only keys recursively and drops nulls', () => {
+ const input = {
+ id: 5,
+ node_id: 'abc',
+ name: 'keep',
+ created_at: 'x',
+ nested: { url: 'u', value: 1, gone: null },
+ list: [{ id: 1, ok: true }]
+ }
+ expect(stripNoise(input)).toEqual({
+ name: 'keep',
+ nested: { value: 1 },
+ list: [{ ok: true }]
+ })
+ })
+ })
+
+ describe('pruneEmpty', () => {
+ it('removes empty arrays, empty objects, null and undefined', () => {
+ expect(pruneEmpty({
+ a: [],
+ b: {},
+ c: null,
+ d: undefined,
+ e: [1],
+ f: { x: 1 },
+ g: 'value'
+ })).toEqual({ e: [1], f: { x: 1 }, g: 'value' })
+ })
+ })
+
+ describe('parsePropertyValue', () => {
+ it('parses name=value', () => {
+ expect(parsePropertyValue('Team=backend')).toEqual({ name: 'Team', value: 'backend' })
+ })
+ it('parses name:value', () => {
+ expect(parsePropertyValue('Team:backend')).toEqual({ name: 'Team', value: 'backend' })
+ })
+ it('uses propertyName when provided', () => {
+ expect(parsePropertyValue('backend', 'Team')).toEqual({ name: 'Team', value: 'backend' })
+ })
+ it('throws when value cannot be parsed', () => {
+ expect(() => parsePropertyValue('backend')).toThrow(/name=value/)
+ })
+ })
+
+ describe('toYaml', () => {
+ it('serializes config to YAML', () => {
+ const out = toYaml({ repository: { name: 'test' } })
+ expect(out).toContain('repository:')
+ expect(out).toContain('name: test')
+ })
+ })
+})
+
+describe('intersectConfigs', () => {
+ it('returns the single config unchanged when only one provided', () => {
+ const cfg = { repository: { has_issues: true } }
+ expect(intersectConfigs([cfg])).toBe(cfg)
+ })
+
+ it('keeps only sections present in all configs', () => {
+ const a = { repository: { has_issues: true }, labels: [{ name: 'bug' }] }
+ const b = { repository: { has_issues: true } }
+ expect(intersectConfigs([a, b])).toEqual({ repository: { has_issues: true } })
+ })
+
+ it('keeps only scalar object keys that match across all configs', () => {
+ const a = { repository: { has_issues: true, has_wiki: true } }
+ const b = { repository: { has_issues: true, has_wiki: false } }
+ expect(intersectConfigs([a, b])).toEqual({ repository: { has_issues: true } })
+ })
+
+ it('keeps array items present (by identity + value) in every config', () => {
+ const a = { labels: [{ name: 'bug', color: 'f00' }, { name: 'wip', color: '0f0' }] }
+ const b = { labels: [{ name: 'bug', color: 'f00' }, { name: 'done', color: '00f' }] }
+ expect(intersectConfigs([a, b])).toEqual({ labels: [{ name: 'bug', color: 'f00' }] })
+ })
+
+ it('drops array items whose value differs even if identity matches', () => {
+ const a = { labels: [{ name: 'bug', color: 'f00' }] }
+ const b = { labels: [{ name: 'bug', color: '00f' }] }
+ expect(intersectConfigs([a, b])).toEqual({ labels: [] })
+ })
+})
+
+describe('SettingsGenerator extractors', () => {
+ it('repository() selects only configurable fields', async () => {
+ const github = {
+ repos: {
+ get: jest.fn().mockResolvedValue({
+ data: {
+ id: 1,
+ node_id: 'x',
+ name: 'test',
+ description: 'desc',
+ has_issues: true,
+ stargazers_count: 99,
+ topics: ['a', 'b'],
+ default_branch: 'main'
+ }
+ })
+ }
+ }
+ const generator = makeGenerator(github)
+ const result = await generator.repository({ owner: 'my-org', repo: 'test' })
+ expect(result).toEqual({
+ name: 'test',
+ description: 'desc',
+ has_issues: true,
+ default_branch: 'main',
+ topics: ['a', 'b']
+ })
+ })
+
+ it('labels() sanitizes to name/color/description', async () => {
+ const generator = makeGenerator()
+ generator.findExisting = jest.fn().mockResolvedValue([
+ { id: 1, node_id: 'n', url: 'u', name: 'bug', color: 'cc0000', description: 'A bug', default: false }
+ ])
+ expect(await generator.labels({ owner: 'my-org', repo: 'r' })).toEqual([
+ { name: 'bug', color: 'cc0000', description: 'A bug' }
+ ])
+ })
+
+ it('teams() maps slug and permission', async () => {
+ const generator = makeGenerator()
+ generator.findExisting = jest.fn().mockResolvedValue([
+ { id: 1, slug: 'core', name: 'Core Team', permission: 'push' }
+ ])
+ expect(await generator.teams({ owner: 'my-org', repo: 'r' })).toEqual([
+ { name: 'core', permission: 'push' }
+ ])
+ })
+
+ it('rulesets() strips source/source_type and noise', async () => {
+ const generator = makeGenerator()
+ generator.findExisting = jest.fn().mockResolvedValue([
+ { id: 7, node_id: 'n', source: 'my-org/r', source_type: 'Repository', name: 'main', enforcement: 'active' }
+ ])
+ expect(await generator.rulesets({ owner: 'my-org', repo: 'r' }, 'repo')).toEqual([
+ { name: 'main', enforcement: 'active' }
+ ])
+ })
+
+ it('reformatBranchProtection flattens enabled wrappers', () => {
+ const generator = makeGenerator()
+ const out = generator.reformatBranchProtection({
+ url: 'noise',
+ enforce_admins: { enabled: true },
+ required_linear_history: { enabled: false },
+ required_pull_request_reviews: { required_approving_review_count: 2 }
+ })
+ expect(out).toEqual({
+ enforce_admins: true,
+ required_linear_history: false,
+ required_pull_request_reviews: { required_approving_review_count: 2 }
+ })
+ })
+})
+
+describe('SettingsGenerator.buildSubOrgConfig', () => {
+ it('prepends suborgproperties and intersects matching repos', async () => {
+ const generator = makeGenerator()
+ generator.findReposByProperty = jest.fn().mockResolvedValue(['repo-a', 'repo-b'])
+ generator.buildRepoConfig = jest.fn()
+ .mockResolvedValueOnce({ repository: { has_issues: true, has_wiki: true } })
+ .mockResolvedValueOnce({ repository: { has_issues: true, has_wiki: false } })
+
+ const result = await generator.buildSubOrgConfig('Team', 'backend')
+ expect(result).toEqual({
+ suborgproperties: [{ Team: 'backend' }],
+ repository: { has_issues: true }
+ })
+ })
+
+ it('returns just the selector when no repos match', async () => {
+ const generator = makeGenerator()
+ generator.findReposByProperty = jest.fn().mockResolvedValue([])
+ const result = await generator.buildSubOrgConfig('Team', 'backend')
+ expect(result).toEqual({ suborgproperties: [{ Team: 'backend' }] })
+ })
+})
+
+describe('SettingsGenerator.generate', () => {
+ it('resolves repo source to repos/.yml', async () => {
+ const generator = makeGenerator()
+ generator.buildRepoConfig = jest.fn().mockResolvedValue({ repository: { name: 'r' } })
+ const { filePath, config, yaml } = await generator.generate({ sourceType: 'repo', sourceValue: 'r' })
+ expect(filePath).toBe('.github/repos/r.yml')
+ expect(config).toEqual({ repository: { name: 'r' } })
+ expect(yaml).toContain('name: r')
+ })
+
+ it('resolves org source to settings.yml', async () => {
+ const generator = makeGenerator()
+ generator.buildOrgConfig = jest.fn().mockResolvedValue({ rulesets: [] })
+ const { filePath } = await generator.generate({ sourceType: 'org', sourceValue: 'my-org' })
+ expect(filePath).toBe('.github/settings.yml')
+ })
+
+ it('resolves custom-property source to suborgs/_.yml', async () => {
+ const generator = makeGenerator()
+ generator.buildSubOrgConfig = jest.fn().mockResolvedValue({ suborgproperties: [{ Team: 'backend' }] })
+ const { filePath } = await generator.generate({ sourceType: 'custom-property', sourceValue: 'Team=backend' })
+ expect(filePath).toBe('.github/suborgs/Team_backend.yml')
+ })
+
+ it('throws on unsupported source type', async () => {
+ const generator = makeGenerator()
+ await expect(generator.generate({ sourceType: 'bogus', sourceValue: 'x' })).rejects.toThrow(/Unsupported source type/)
+ })
+})
+
+describe('SettingsGenerator.findReposByProperty', () => {
+ it('queries the org properties values API and returns repo names', async () => {
+ const paginate = jest.fn().mockResolvedValue([
+ { repository_name: 'repo-a' },
+ { repository_name: 'repo-b' },
+ { repository_name: null }
+ ])
+ const github = {
+ request: { endpoint: jest.fn().mockReturnValue({ url: 'endpoint' }) },
+ paginate
+ }
+ const generator = makeGenerator(github)
+ const repos = await generator.findReposByProperty('Team', 'backend')
+ expect(github.request.endpoint).toHaveBeenCalledWith(
+ expect.stringContaining('/orgs/my-org/properties/values?repository_query=')
+ )
+ expect(repos).toEqual(['repo-a', 'repo-b'])
+ })
+})
From 5fac715f271114a0d23d020cea0a0eed045fde0d Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 16 Jun 2026 23:40:04 -0400
Subject: [PATCH 21/67] feat: enhance smoke tests with custom repository roles
and rulesets
- Added support for custom repository roles in smoke-test.js, including creation, deletion, and retrieval functions.
- Implemented new ruleset management functions for organizations and repositories.
- Updated smoke tests to validate the behavior of custom repository roles and rulesets under various scenarios.
- Enhanced existing tests to ensure proper handling of additive and disabled plugins for custom repository roles and rulesets.
- Introduced new test cases to cover scenarios where suborg configurations change and their impact on repository rulesets.
- Improved error handling and logging for better traceability during tests.
---
README.md | 14 +-
index.js | 21 +-
lib/commentmessage.js | 2 +-
lib/configManager.js | 4 +-
lib/plugins/rulesets.js | 1 +
lib/settings.js | 64 ++-
smoke-test.js | 536 ++++++++++++++++++++++++-
test/unit/lib/plugins/rulesets.test.js | 46 ++-
test/unit/lib/settings.test.js | 85 +++-
9 files changed, 703 insertions(+), 70 deletions(-)
diff --git a/README.md b/README.md
index 807fe7d22..f1db36632 100644
--- a/README.md
+++ b/README.md
@@ -189,7 +189,7 @@ The comment contains:
- A header with the run timestamp, the **number of repos considered**, and the **number of repos affected**.
- **Breakdown of changes** — a collapsible section, grouped by plugin/repo, showing field-level diffs. Each entry is marked as an addition, modification, or deletion, with the before/after values for modified fields. When there are no changes, it shows `No changes to apply.`
- **Breakdown of errors** — a collapsible section listing any errors by repo, or `None` when there are none. The check run is marked as failed when errors are present.
-- **Informational messages (disabled plugins)** — a collapsible section listing plugins that were skipped via [`disable_plugins`](#disabling-plugins-disable_plugins), so reviewers can see which settings were intentionally not applied.
+- **Informational messages** — a collapsible section listing non-error notices such as plugins skipped via [`disable_plugins`](#disabling-plugins-disable_plugins) or deletions suppressed by `additive_plugins`, so reviewers can see which settings were intentionally not applied.
For very large diffs the comment is split across multiple comments, and the check-run summary is truncated with a notice when it exceeds the size limit.
@@ -201,18 +201,18 @@ A repo's suborg membership can depend on state that is itself written by `safe-s
- `suborgproperties` — repos belong to a suborg because a custom property has a given value
- `suborgrepos` — repos belong to a suborg because their name matches a glob
-When a repo-level change (a push to `.github/repos/.yml`, or a `repository.created` event for a brand-new repo) adds a team, sets a custom property, or creates a repo whose name matches a suborg's `suborgrepos` glob, the repo may *newly* match a suborg config that was not applied in the first pass.
+When a repo-level change (a push to `.github/repos/.yml`, or a `repository.created` event for a brand-new repo) adds, removes, or changes a team or custom property, the repo may start or stop matching a suborg config. A new repo may also start matching a suborg because its name matches a `suborgrepos` glob.
-To handle this, after applying a repo-yml change `safe-settings` re-evaluates the repo's suborg membership and, if a new suborg now matches, runs the repo through the apply pipeline a second time so the suborg's settings are picked up in the same sync.
+To handle this, after applying a repo-yml change `safe-settings` re-evaluates the repo's suborg membership. If the matched suborg source set changed, it runs the repo through the apply pipeline a second time so newly matched suborg settings are applied and settings from a no-longer-matching suborg can be removed in the same sync.
**Scope:** Re-evaluation runs only on the repo-yml change paths (`Settings.sync` and the per-repo loop of `Settings.syncSelectedRepos`). Global settings changes (`syncAll`) and suborg-yml changes (`syncSubOrgs`) already iterate all relevant repos and do not need it.
**Loop prevention.** Two guards prevent infinite re-evaluation:
-1. **Stability check (primary):** Before applying changes, `safe-settings` snapshots the set of suborg source paths that match the repo. After applying, it refreshes the suborg cache and recomputes the set. If no new suborg source appeared, re-evaluation stops.
-2. **Hard depth cap (safety net):** Each repo is re-evaluated at most `MAX_REEVALUATION_DEPTH = 1` time per sync. This resolves the dominant single-hop case (repo change → newly-matched suborg → apply suborg once) while preventing pathological chains (suborg A applies a team that activates suborg B that activates suborg C…). Chains beyond one hop are resolved on the next sync event, and a warning is logged when the cap is hit.
+1. **Stability check (primary):** Before applying changes, `safe-settings` snapshots the set of suborg source paths that match the repo. After applying, it refreshes the suborg cache and recomputes the set. If the set did not change, re-evaluation stops. If a source appeared or disappeared, the repo is processed once more.
+2. **Hard depth cap (safety net):** Each repo is re-evaluated at most `MAX_REEVALUATION_DEPTH = 1` time per sync. This resolves the dominant single-hop case (repo change → suborg membership changed → apply the corrected suborg overlay once) while preventing pathological chains (suborg A applies a team that activates suborg B that activates suborg C…). Chains beyond one hop are resolved on the next sync event, and a warning is logged when the cap is hit.
-**Trigger optimization.** Re-evaluation is skipped entirely when the resolved `repoConfig` has no `teams`, no `custom_properties`, and is not a rename — these are the only repo-level changes that can affect suborg matching.
+**Trigger optimization.** Re-evaluation is skipped entirely when the applied repo change did not affect `teams`, `custom_properties`, repository creation, or repository rename state — these are the repo-level changes that can affect suborg matching.
### Use `safe-settings` to rename repos
If you rename a `` that corresponds to a repo, safe-settings will rename the repo to the new name. This behavior will take effect whether the env variable `BLOCK_REPO_RENAME_BY_HUMAN` is set or not.
@@ -804,7 +804,7 @@ The smoke test runs the following phases:
| **Phase 2** | Removes a team from the repo and verifies safe-settings re-adds it (drift remediation) |
| **Phase 3** | Creates a rogue ruleset and verifies safe-settings removes it (drift remediation) |
| **Phase 4** | Creates `demo-repo-service1` with teams, topics, and branch protection |
-| **Phase 5** | Creates a suborg config and verifies org-scoped rulesets are applied to matching repos |
+| **Phase 5** | Creates a property-targeted suborg config, verifies suborg rulesets apply to two matching repos, then changes one repo's custom property and verifies the ruleset is removed only from the repo that no longer matches |
| **Phase 6** | Archives `demo-repo-service1` and verifies the repo is archived |
| **Phase 7** | Creates `demo-repo-service2` and verifies suborg rulesets are inherited |
| **Phase 7b** | Tests external group team sync |
diff --git a/index.js b/index.js
index 860d0474a..09ba59df9 100644
--- a/index.js
+++ b/index.js
@@ -281,15 +281,6 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return
}
- const settingsModified = payload.commits.find(commit => {
- return commit.added.includes(Settings.FILE_PATH) ||
- commit.modified.includes(Settings.FILE_PATH)
- })
- if (settingsModified) {
- robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`)
- return syncAllSettings(false, context)
- }
-
let repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
let subOrgChanges = getAllChangedSubOrgConfigs(payload)
@@ -299,6 +290,18 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
robot.log.debug(`deduped repos ${JSON.stringify(repoChanges)}`)
robot.log.debug(`deduped subOrgs ${JSON.stringify(subOrgChanges)}`)
+ const settingsModified = payload.commits.find(commit => {
+ return commit.added.includes(Settings.FILE_PATH) ||
+ commit.modified.includes(Settings.FILE_PATH)
+ })
+ if (settingsModified) {
+ robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`)
+ return syncAllSettings(false, context, context.repo(), payload.after, null, {
+ repos: repoChanges,
+ subOrgs: subOrgChanges
+ })
+ }
+
if (repoChanges.length > 0 || subOrgChanges.length > 0) {
return syncSelectedSettings(false, context, repoChanges, subOrgChanges)
}
diff --git a/lib/commentmessage.js b/lib/commentmessage.js
index 292c9ed5f..dc932402b 100644
--- a/lib/commentmessage.js
+++ b/lib/commentmessage.js
@@ -24,7 +24,7 @@ No changes to apply.
<% } %>
-### Informational messages (disabled plugins)
+### Informational messages
<% if (!it.infos || Object.keys(it.infos).length === 0) { %>
\`None\`
diff --git a/lib/configManager.js b/lib/configManager.js
index 58f5bb436..e7356da18 100644
--- a/lib/configManager.js
+++ b/lib/configManager.js
@@ -19,9 +19,7 @@ module.exports = class ConfigManager {
try {
const repo = { owner: this.context.repo().owner, repo: env.ADMIN_REPO }
const params = Object.assign(repo, { path: filePath, ref: this.ref })
- const response = await this.context.octokit.repos.getContent(params).catch(e => {
- this.log.error(`Error getting settings ${e}`)
- })
+ const response = await this.context.octokit.repos.getContent(params)
// Ignore in case path is a folder
// - https://developer.github.com/v3/repos/contents/#response-if-content-is-a-directory
diff --git a/lib/plugins/rulesets.js b/lib/plugins/rulesets.js
index b77ead1bd..c9be14647 100644
--- a/lib/plugins/rulesets.js
+++ b/lib/plugins/rulesets.js
@@ -81,6 +81,7 @@ module.exports = class Rulesets extends Diffable {
return res ? res.flat(1) : []
})
}).catch(e => {
+ if (this.nop && e.status === 404) return []
return this.handleError(e, [])
})
}
diff --git a/lib/settings.js b/lib/settings.js
index 7e1eae273..9242044e7 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -45,7 +45,7 @@ const COMMENT_LIMIT = 55536
const yaml = require('js-yaml')
// When a repo-yml change applies teams/properties/etc to a repo, the repo may
-// newly match a suborg config (via suborgteams/suborgproperties/suborgrepos).
+// change suborg config matches (via suborgteams/suborgproperties/suborgrepos).
// Re-run updateRepos for the same repo at most this many times. Depth=1 is the
// tightest cap: we resolve a single hop of newly-matched suborg per sync.
const MAX_REEVALUATION_DEPTH = 1
@@ -563,6 +563,7 @@ class Settings {
// settings.repoConfigs = await settings.getRepoConfigs()
await settings.updateOrg()
await settings.updateAll()
+ await settings.updateChangedRepoConfigs(changedFiles.repos)
await settings.handleResults()
} catch (error) {
settings.logError(error.message)
@@ -688,6 +689,7 @@ class Settings {
this.reevaluateOnChange = false
this.reevaluationDepth = new Map()
this.reevaluatedRepos = new Map()
+ this.processedRepoNames = new Set()
}
// Record which repo override files and suborg config files changed in the PR.
@@ -824,6 +826,10 @@ class Settings {
this.results = this.results.filter(res => {
if (!res || res.type === 'ERROR') return true
+ if (res.type === 'INFO' && res.action?.msg && res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null) {
+ return true
+ }
+
const isOrgLevel = res.repo && res.repo.endsWith('(org)')
const pluginSection = res.plugin ? res.plugin.toLowerCase() : null
@@ -934,7 +940,7 @@ class Settings {
const infoRepos = Object.keys(stats.infos)
const infoSection = infoRepos.length === 0
? ''
- : `### Informational messages (disabled plugins)\n\n:information_source: Info — ${infoRepos.length} ${pluralize(infoRepos.length, 'repo', 'repos')} \n\n${
+ : `### Informational messages\n\n:information_source: Info — ${infoRepos.length} ${pluralize(infoRepos.length, 'repo', 'repos')} \n\n${
infoRepos.map(repo =>
`**${repo}**:\n${stats.infos[repo].map(msg => `* :information_source: ${msg}`).join('\n')}`
).join('\n\n')
@@ -1178,6 +1184,7 @@ class Settings {
// Org-execution stripMap: no repo context, so only deployment + org
// disable_plugins contribute.
const stripMap = this.computeStripMap()
+ const additiveSet = this.normalizeAdditivePlugins()
const rulesetsConfig = this.config.rulesets
if (rulesetsConfig) {
@@ -1186,7 +1193,9 @@ class Settings {
this.emitDisableSkip('rulesets')
} else {
const RulesetsPlugin = Settings.PLUGINS.rulesets
- await new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG).sync().then(res => {
+ const rulesetsPlugin = new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG)
+ rulesetsPlugin.additive = additiveSet.has('rulesets')
+ await rulesetsPlugin.sync().then(res => {
if (this.nop && Array.isArray(res)) {
res.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` })
}
@@ -1202,7 +1211,9 @@ class Settings {
this.emitDisableSkip('custom_repository_roles')
} else {
const CustomRepositoryRolesPlugin = Settings.PLUGINS.custom_repository_roles
- await new CustomRepositoryRolesPlugin(this.nop, this.github, this.repo, customRepositoryRolesConfig, this.log, this.errors).sync().then(res => {
+ const customRepositoryRolesPlugin = new CustomRepositoryRolesPlugin(this.nop, this.github, this.repo, customRepositoryRolesConfig, this.log, this.errors)
+ customRepositoryRolesPlugin.additive = additiveSet.has('custom_repository_roles')
+ await customRepositoryRolesPlugin.sync().then(res => {
this.appendToResults(res)
})
}
@@ -1331,9 +1342,10 @@ class Settings {
// Suborg re-evaluation: if a repo-yml change actually applied teams or
// custom_properties (or this repo was just renamed/created), the repo
- // may newly match a suborg config (suborgteams/suborgproperties/
- // suborgrepos). Refresh the suborg cache, compare matched-source sets;
- // if it grew, re-run updateRepos once for this repo. Bounded by
+ // may newly match or stop matching a suborg config
+ // (suborgteams/suborgproperties/suborgrepos). Refresh the suborg cache,
+ // compare matched-source sets; if the set changed, re-run updateRepos
+ // once for this repo. Bounded by
// MAX_REEVALUATION_DEPTH and a stable-set check to prevent loops.
await this.maybeReevaluateSuborg(repo, repoConfig, preMatchedSuborgSources, changeSignals)
} else {
@@ -1358,6 +1370,18 @@ class Settings {
})
}
+ async updateChangedRepoConfigs (changedRepos = []) {
+ if (!Array.isArray(changedRepos) || changedRepos.length === 0) return
+
+ const seen = new Set()
+ for (const repo of changedRepos) {
+ if (!repo || !repo.repo || seen.has(repo.repo)) continue
+ seen.add(repo.repo)
+ if (this.processedRepoNames.has(repo.repo)) continue
+ await this.checkAndProcessRepo(repo.owner || this.repo.owner, repo.repo)
+ }
+ }
+
getSubOrgConfig (repoName) {
if (this.subOrgConfigs) {
for (const pattern of Object.keys(this.subOrgConfigs)) {
@@ -1438,7 +1462,7 @@ class Settings {
}
// After applying changes to a repo, decide whether to re-run updateRepos
- // because the applied changes may have caused the repo to newly match a
+ // because the applied changes may have changed whether the repo matches a
// suborg config. Loop prevention has two layers:
// 1. Hard cap: MAX_REEVALUATION_DEPTH (=1) re-evaluation passes per repo.
// 2. Stability check: stop if the set of matched suborg sources did not
@@ -1461,25 +1485,28 @@ class Settings {
// hits live GitHub APIs and may now match this repo.
await this.reloadSubOrgConfigs()
- const seen = this.reevaluatedRepos.get(repo.repo) || new Set(preMatchedSuborgSources)
const newMatched = this.getAllMatchingSubOrgSources(repo.repo)
- // Stability check: if no new suborg source appeared, we're done.
- let hasNew = false
- for (const source of newMatched) {
- if (!seen.has(source)) {
- hasNew = true
- seen.add(source)
+ // Stability check: if the source set did not change, we're done. A change
+ // can be either a newly matched suborg or a removed match after teams or
+ // custom_properties changed.
+ let hasChanged = preMatchedSuborgSources.size !== newMatched.size
+ if (!hasChanged) {
+ for (const source of newMatched) {
+ if (!preMatchedSuborgSources.has(source)) {
+ hasChanged = true
+ break
+ }
}
}
- if (!hasNew) {
+ if (!hasChanged) {
this.log.debug(`Suborg re-eval: stable for ${repo.repo} (matched sources: ${JSON.stringify(Array.from(newMatched))}); stopping.`)
return
}
- this.reevaluatedRepos.set(repo.repo, seen)
+ this.reevaluatedRepos.set(repo.repo, new Set([...preMatchedSuborgSources, ...newMatched]))
this.reevaluationDepth.set(repo.repo, depth + 1)
- this.log.debug(`Suborg re-eval: new suborg source(s) matched ${repo.repo} after apply; re-running updateRepos (depth=${depth + 1}).`)
+ this.log.debug(`Suborg re-eval: suborg sources changed for ${repo.repo} after apply; re-running updateRepos (depth=${depth + 1}).`)
// Reload repo-level configs for this repo so the next pass picks up any
// state changes; then recurse. Depth cap above prevents infinite loops.
@@ -1651,6 +1678,7 @@ class Settings {
}
async checkAndProcessRepo (owner, name) {
+ this.processedRepoNames.add(name)
if (this.isRestricted(name)) {
return null
}
diff --git a/smoke-test.js b/smoke-test.js
index 39bf5cbf0..3373a2c39 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -67,7 +67,7 @@ const CONFIG_PATH = process.env.CONFIG_PATH || '.github'
const APP_ID = process.env.APP_ID
const PRIVATE_KEY = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n')
-const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2']
+const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo']
const TEST_TEAMS = ['AD-GRP-PAYMENTS-PLATFORM-OWNERS', 'awesometeam-a-approvers', 'jefeish-edj-test']
const POLL_INTERVAL_MS = 5000
@@ -289,6 +289,83 @@ async function deleteTeam (org, teamSlug) {
try { await octokit.rest.teams.deleteInOrg({ org, team_slug: teamSlug }) } catch { /* ok */ }
}
+async function getCustomRepositoryRole (org, name) {
+ try {
+ const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org })
+ return (data.custom_roles || []).find(role => role.name === name) || null
+ } catch { return null }
+}
+
+async function createCustomRepositoryRole (org, name, description) {
+ const existing = await getCustomRepositoryRole(org, name)
+ if (existing) return existing
+ return (await octokit.request('POST /orgs/{org}/custom-repository-roles', {
+ org,
+ name,
+ description,
+ base_role: 'read',
+ permissions: ['delete_alerts_code_scanning']
+ })).data
+}
+
+async function deleteCustomRepositoryRole (org, name) {
+ const role = await getCustomRepositoryRole(org, name)
+ if (!role) return
+ await octokit.request('DELETE /orgs/{org}/custom-repository-roles/{role_id}', { org, role_id: role.id })
+}
+
+async function getOrgRuleset (org, name) {
+ try {
+ const { data: rulesets } = await octokit.request('GET /orgs/{org}/rulesets', { org })
+ return rulesets.find(ruleset => ruleset.name === name) || null
+ } catch { return null }
+}
+
+async function getRepoRuleset (owner, repo, name) {
+ try {
+ const { data: rulesets } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner, repo })
+ return rulesets.find(ruleset => ruleset.name === name) || null
+ } catch { return null }
+}
+
+async function setRepoCustomProperty (owner, repo, propertyName, value) {
+ await octokit.request('PATCH /repos/{owner}/{repo}/properties/values', {
+ owner,
+ repo,
+ properties: [
+ { property_name: propertyName, value }
+ ]
+ })
+}
+
+async function createOrgRuleset (org, name) {
+ const existing = await getOrgRuleset(org, name)
+ if (existing) return existing
+ return (await octokit.request('POST /orgs/{org}/rulesets', {
+ org,
+ name,
+ target: 'repository',
+ source_type: 'Organization',
+ source: org,
+ enforcement: 'disabled',
+ conditions: {
+ repository_property: {
+ exclude: [],
+ include: [
+ { name: 'visibility', source: 'system', property_values: ['private'] }
+ ]
+ }
+ },
+ rules: [{ type: 'repository_delete' }]
+ })).data
+}
+
+async function deleteOrgRuleset (org, name) {
+ const ruleset = await getOrgRuleset(org, name)
+ if (!ruleset) return
+ await octokit.request('DELETE /orgs/{org}/rulesets/{ruleset_id}', { org, ruleset_id: ruleset.id })
+}
+
async function waitForCheckRun (owner, repo, sha, { timeout = MAX_POLL_MS } = {}) {
return poll(async () => {
const { data } = await octokit.rest.checks.listForRef({ owner, repo, ref: sha })
@@ -423,6 +500,11 @@ rulesets:
security_alerts_threshold: medium_or_higher
`
+const REPO_TEST_OTHER_OWNERSHIP_YML = REPO_TEST_YML.replace(
+ ' - property_name: ent-ownership\n value: expert-services',
+ ' - property_name: ent-ownership\n value: other-services'
+)
+
const REPO_DEMO_SERVICE1_YML = `# Safe-Settings Configuration
repository:
name: demo-repo-service1
@@ -532,6 +614,11 @@ rulesets:
type: Team
`
+const SUBORG_EXPERT_SERVICES_PROPERTY_YML = SUBORG_EXPERT_SERVICES_YML.replace(
+ 'suborgteams:\n - expert-services-developers',
+ 'suborgproperties:\n - ent-ownership: expert-services'
+)
+
const REPO_DEMO_SERVICE1_ARCHIVED_YML = `# Safe-Settings Configuration
repository:
name: demo-repo-service1
@@ -833,6 +920,126 @@ custom_properties:
value: baseline
`
+const SETTINGS_YML_CRR_SMOKE_ADDITIVE = `# Org-level custom repository roles with additive mode
+additive_plugins:
+ - custom_repository_roles
+custom_repository_roles:
+ - name: smoke-crr-managed
+ description: Managed by safe-settings in additive custom role smoke test
+ base_role: maintain
+ permissions:
+ - delete_alerts_code_scanning
+`
+
+const SETTINGS_YML_CRR_SMOKE_DISABLE = `# Org-level custom repository roles disabled at self
+disable_plugins:
+ - plugin: custom_repository_roles
+ target: self
+custom_repository_roles:
+ - name: smoke-crr-disabled
+ description: This role must not be created because custom_repository_roles is disabled
+ base_role: read
+ permissions:
+ - delete_alerts_code_scanning
+`
+
+const SETTINGS_YML_RULESETS_SMOKE_ADDITIVE = `# Org-level rulesets with additive mode
+additive_plugins:
+ - rulesets
+rulesets:
+ - name: smoke-ruleset-managed
+ target: repository
+ source_type: Organization
+ source: ${ORG}
+ enforcement: disabled
+ conditions:
+ repository_property:
+ exclude: []
+ include:
+ - name: visibility
+ source: system
+ property_values:
+ - private
+ rules:
+ - type: repository_delete
+`
+
+const SETTINGS_YML_RULESETS_SMOKE_DISABLE = `# Org-level rulesets disabled at self
+disable_plugins:
+ - plugin: rulesets
+ target: self
+rulesets:
+ - name: smoke-ruleset-disabled
+ target: repository
+ source_type: Organization
+ source: ${ORG}
+ enforcement: disabled
+ conditions:
+ repository_property:
+ exclude: []
+ include:
+ - name: visibility
+ source: system
+ property_values:
+ - private
+ rules:
+ - type: repository_delete
+`
+
+const SETTINGS_YML_COMBINED_ORG_AND_REPO = `# Org-level settings changed in the same commit as a new repo.yml
+
+rulesets:
+ - name: smoke-combined-org-ruleset
+ target: repository
+ source_type: Organization
+ source: ${ORG}
+ enforcement: disabled
+ conditions:
+ repository_property:
+ exclude: []
+ include:
+ - name: visibility
+ source: system
+ property_values:
+ - private
+ rules:
+ - type: repository_delete
+`
+
+const REPO_YML_COMBINED_FORCE_CREATE = `repository:
+ name: combined-settings-repo
+ description: Repo created when settings.yml and repo.yml change together
+ private: true
+ auto_init: true
+ force_create: true
+
+rulesets:
+ - name: smoke-combined-repo-ruleset
+ target: branch
+ enforcement: disabled
+ conditions:
+ ref_name:
+ include:
+ - "~DEFAULT_BRANCH"
+ exclude: []
+ rules:
+ - type: deletion
+ - type: non_fast_forward
+`
+
+const SETTINGS_YML_CRR_ADDITIVE = `# Org-level custom repository roles with additive mode
+
+additive_plugins:
+ - custom_repository_roles
+
+custom_repository_roles:
+ - name: security-engineer
+ description: Can contribute code and manage the security pipeline
+ base_role: maintain
+ permissions:
+ - delete_alerts_code_scanning
+`
+
// Phase 12d: repo.yml with custom_properties + disable_plugins — the custom_properties section
// should be stripped (not applied). Org-level custom_properties are unaffected.
const REPO_YML_CP_DISABLE = `repository:
@@ -1094,12 +1301,23 @@ async function phase5Suborg () {
logPhase('Phase 5: Create suborg config')
const branch = 'smoke-test-phase5'
const defaultBranch = await getDefaultBranch()
+ const suborgRulesetName = 'Protect release and production branches'
+
+ log('Setting ent-ownership=expert-services on demo-repo-service1 for suborg property targeting...')
+ await setRepoCustomProperty(ORG, 'demo-repo-service1', 'ent-ownership', 'expert-services')
+ const demo1Property = await poll(async () => {
+ try {
+ const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'demo-repo-service1' })
+ return Array.isArray(props) && props.find(p => p.property_name === 'ent-ownership' && p.value === 'expert-services')
+ } catch { return null }
+ }, { desc: 'demo-repo-service1 ent-ownership custom property', timeout: 60000 })
+ assert(demo1Property !== null, 'demo-repo-service1 has ent-ownership=expert-services for suborg property targeting')
await deleteBranch(ORG, ADMIN_REPO, branch)
await createBranch(ORG, ADMIN_REPO, branch)
- await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_YML, branch, 'Add expert-services suborg config')
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_PROPERTY_YML, branch, 'Add property-targeted expert-services suborg config')
- const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add expert-services suborg', branch, defaultBranch)
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add property-targeted expert-services suborg', branch, defaultBranch)
log('Waiting for NOP check run...')
await sleep(WEBHOOK_SETTLE_MS)
const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
@@ -1109,16 +1327,61 @@ async function phase5Suborg () {
if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
await sleep(WEBHOOK_SETTLE_MS)
- log('Checking suborg ruleset on demo-repo-service1...')
- const ruleset = await poll(async () => {
- try {
- const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'demo-repo-service1' })
- return rs.find(r => r.name === 'Protect release and production branches') || null
- } catch { return null }
- }, { desc: 'suborg ruleset on demo-repo-service1', timeout: 60000 })
+ log('Checking property-targeted suborg ruleset on test and demo-repo-service1...')
+ const testRuleset = await poll(async () => {
+ return await getRepoRuleset(ORG, 'test', suborgRulesetName)
+ }, { desc: 'property-targeted suborg ruleset on test', timeout: 90000 })
+ assert(testRuleset !== null, 'Property-targeted suborg ruleset applied to test')
+
+ const demo1Ruleset = await poll(async () => {
+ return await getRepoRuleset(ORG, 'demo-repo-service1', suborgRulesetName)
+ }, { desc: 'property-targeted suborg ruleset on demo-repo-service1', timeout: 90000 })
+ assert(demo1Ruleset !== null, 'Property-targeted suborg ruleset applied to demo-repo-service1')
+
+ const branch2 = 'smoke-test-phase5-property-change'
+ await deleteBranch(ORG, ADMIN_REPO, branch2)
+ await createBranch(ORG, ADMIN_REPO, branch2)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_OTHER_OWNERSHIP_YML, branch2, 'Change test repo ent-ownership custom property')
+
+ const pr2 = await createPR(ORG, ADMIN_REPO, 'Smoke test: remove test from property-targeted suborg', branch2, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun2 = await waitForCheckRun(ORG, ADMIN_REPO, pr2.head.sha)
+ assert(checkRun2 !== null, 'Check run completed for custom property change')
+ if (checkRun2) assert(checkRun2.conclusion === 'success', `Check run conclusion is success (got: ${checkRun2.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr2.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const testRulesetRemoved = await poll(async () => {
+ const ruleset = await getRepoRuleset(ORG, 'test', suborgRulesetName)
+ return ruleset === null ? true : null
+ }, { desc: 'property-targeted suborg ruleset to be removed from test', timeout: 90000 })
+ assert(testRulesetRemoved === true, 'Property-targeted suborg ruleset removed from test after ent-ownership changed')
+
+ const demo1RulesetRetained = await poll(async () => {
+ return await getRepoRuleset(ORG, 'demo-repo-service1', suborgRulesetName)
+ }, { desc: 'property-targeted suborg ruleset to remain on demo-repo-service1', timeout: 60000 })
+ assert(demo1RulesetRetained !== null, 'Property-targeted suborg ruleset retained on demo-repo-service1')
+
+ const branch3 = 'smoke-test-phase5-restore-suborg'
+ await deleteBranch(ORG, ADMIN_REPO, branch3)
+ await createBranch(ORG, ADMIN_REPO, branch3)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_YML, branch3, 'Restore team-targeted expert-services suborg config')
+
+ const pr3 = await createPR(ORG, ADMIN_REPO, 'Smoke test: restore team-targeted expert-services suborg', branch3, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun3 = await waitForCheckRun(ORG, ADMIN_REPO, pr3.head.sha)
+ assert(checkRun3 !== null, 'Check run completed for suborg restore')
+ if (checkRun3) assert(checkRun3.conclusion === 'success', `Check run conclusion is success (got: ${checkRun3.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr3.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
- assert(ruleset !== null, 'Suborg ruleset applied to demo-repo-service1')
await deleteBranch(ORG, ADMIN_REPO, branch)
+ await deleteBranch(ORG, ADMIN_REPO, branch2)
+ await deleteBranch(ORG, ADMIN_REPO, branch3)
}
async function phase6Archive () {
@@ -1691,6 +1954,143 @@ async function phase12CustomProperties () {
}
}
+async function phase12CustomRoles () {
+ logPhase('Phase 12: custom_repository_roles additive/disable_plugins')
+ const defaultBranch = await getDefaultBranch()
+
+ // 12e: Add role outside safe-settings, re-run with additive mode, verify it is NOT removed.
+ {
+ log('12e: Adding external custom repository role outside safe-settings...')
+ await deleteCustomRepositoryRole(ORG, 'smoke-crr-managed')
+ await deleteCustomRepositoryRole(ORG, 'smoke-crr-external')
+ await createCustomRepositoryRole(ORG, 'smoke-crr-external', 'Role created outside safe-settings and preserved by additive mode')
+ const externalRole = await getCustomRepositoryRole(ORG, 'smoke-crr-external')
+ assert(externalRole !== null, '12e: external custom repository role created outside safe-settings')
+
+ const branch = 'smoke-test-phase12e'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CRR_SMOKE_ADDITIVE, branch, '12e: additive custom repository roles')
+ const pr = await createPR(ORG, ADMIN_REPO, '12e: additive custom repository roles', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '12e: NOP check run completed')
+ if (checkRun) {
+ assert(checkRun.conclusion === 'success', `12e: NOP check run is success (got: ${checkRun.conclusion})`)
+ const crOutput = checkRun.output && (checkRun.output.summary || '')
+ assert(/additive|suppress/i.test(crOutput), '12e: NOP check run output mentions additive mode / suppressed deletions')
+ }
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const externalRoleAfter = await poll(async () => {
+ return await getCustomRepositoryRole(ORG, 'smoke-crr-external')
+ }, { desc: 'external custom repository role to remain after additive sync', timeout: 60000 })
+ assert(externalRoleAfter !== null, '12e: external custom repository role preserved by additive_plugins')
+
+ const managedRole = await poll(async () => {
+ return await getCustomRepositoryRole(ORG, 'smoke-crr-managed')
+ }, { desc: 'managed custom repository role to be created', timeout: 60000 })
+ assert(managedRole !== null, '12e: managed custom repository role created')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
+ // 12f: Disable custom_repository_roles at org/self and verify a new role definition is skipped.
+ {
+ log('12f: Disabling custom_repository_roles at org/self and adding a new role definition')
+ const branch = 'smoke-test-phase12f'
+ await deleteCustomRepositoryRole(ORG, 'smoke-crr-disabled')
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CRR_SMOKE_DISABLE, branch, '12f: disable custom repository roles')
+ const pr = await createPR(ORG, ADMIN_REPO, '12f: disable custom repository roles', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '12f: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `12f: NOP check run is success (got: ${checkRun.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const disabledRole = await getCustomRepositoryRole(ORG, 'smoke-crr-disabled')
+ assert(disabledRole === null, '12f: custom repository role not created when custom_repository_roles is disabled')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+}
+
+async function phase12Rulesets () {
+ logPhase('Phase 12: rulesets additive/disable_plugins')
+ const defaultBranch = await getDefaultBranch()
+
+ // 12g: Add org ruleset outside safe-settings, re-run with additive mode, verify it is NOT removed.
+ {
+ log('12g: Adding external org ruleset outside safe-settings...')
+ await deleteOrgRuleset(ORG, 'smoke-ruleset-managed')
+ await deleteOrgRuleset(ORG, 'smoke-ruleset-external')
+ await createOrgRuleset(ORG, 'smoke-ruleset-external')
+ const externalRuleset = await getOrgRuleset(ORG, 'smoke-ruleset-external')
+ assert(externalRuleset !== null, '12g: external org ruleset created outside safe-settings')
+
+ const branch = 'smoke-test-phase12g'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_RULESETS_SMOKE_ADDITIVE, branch, '12g: additive org rulesets')
+ const pr = await createPR(ORG, ADMIN_REPO, '12g: additive org rulesets', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '12g: NOP check run completed')
+ if (checkRun) {
+ assert(checkRun.conclusion === 'success', `12g: NOP check run is success (got: ${checkRun.conclusion})`)
+ const crOutput = checkRun.output && (checkRun.output.summary || '')
+ log(`12g: NOP check run output: ${crOutput}`)
+ assert(/additive|suppress/i.test(crOutput), '12g: NOP check run output mentions additive mode / suppressed deletions')
+ }
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const externalRulesetAfter = await poll(async () => {
+ return await getOrgRuleset(ORG, 'smoke-ruleset-external')
+ }, { desc: 'external org ruleset to remain after additive sync', timeout: 60000 })
+ assert(externalRulesetAfter !== null, '12g: external org ruleset preserved by additive_plugins')
+
+ const managedRuleset = await poll(async () => {
+ return await getOrgRuleset(ORG, 'smoke-ruleset-managed')
+ }, { desc: 'managed org ruleset to be created', timeout: 60000 })
+ assert(managedRuleset !== null, '12g: managed org ruleset created')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
+ // 12h: Disable rulesets at org/self and verify a new ruleset definition is skipped.
+ {
+ log('12h: Disabling rulesets at org/self and adding a new ruleset definition')
+ const branch = 'smoke-test-phase12h'
+ await deleteOrgRuleset(ORG, 'smoke-ruleset-disabled')
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_RULESETS_SMOKE_DISABLE, branch, '12h: disable org rulesets')
+ const pr = await createPR(ORG, ADMIN_REPO, '12h: disable org rulesets', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '12h: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `12h: NOP check run is success (got: ${checkRun.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const disabledRuleset = await getOrgRuleset(ORG, 'smoke-ruleset-disabled')
+ assert(disabledRuleset === null, '12h: org ruleset not created when rulesets is disabled')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+}
+
async function phase13Variables () {
logPhase('Phase 13: Variables plugin — create, NOP check, update, verify')
const defaultBranch = await getDefaultBranch()
@@ -1783,6 +2183,94 @@ async function phase13Variables () {
}
}
+async function phase14RegressionCoverage () {
+ logPhase('Phase 14: Regression coverage - mixed changes and additive custom roles')
+ const defaultBranch = await getDefaultBranch()
+
+ // 14a: A single PR changes settings.yml and adds a new repos/*.yml. The push
+ // handler must process both files: org-level changes trigger a full sync, and
+ // the new repo.yml must still be force-created and get repo rulesets.
+ {
+ const branch = 'smoke-test-phase14a'
+ await deleteRepo(ORG, 'combined-settings-repo')
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_COMBINED_ORG_AND_REPO, branch, '14a: update org settings')
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/combined-settings-repo.yml`, REPO_YML_COMBINED_FORCE_CREATE, branch, '14a: add combined-settings-repo config')
+
+ const pr = await createPR(ORG, ADMIN_REPO, '14a: settings.yml plus new repo.yml', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '14a: NOP check run completed')
+ if (checkRun) {
+ assert(checkRun.conclusion === 'success', `14a: NOP check run is success (got: ${checkRun.conclusion})`)
+ const crOutput = checkRun.output && (checkRun.output.summary || '')
+ const errorsSectionMatch = crOutput.match(/### (?:Breakdown of errors|Errors)\n([\s\S]*?)(?:\n### |\n#### |$)/i)
+ const errorsSection = errorsSectionMatch ? errorsSectionMatch[1] : ''
+ assert(!/\bRulesets\b/i.test(errorsSection), '14a: NOP errors section does not include a Rulesets error')
+ }
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const repo = await poll(async () => {
+ try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'combined-settings-repo' })).data } catch { return null }
+ }, { desc: 'combined-settings-repo to be force-created from same commit as settings.yml', timeout: 90000 })
+ assert(repo !== null, '14a: combined-settings-repo was created')
+
+ const repoRuleset = await poll(async () => {
+ try {
+ const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'combined-settings-repo' })
+ return rs.find(r => r.name === 'smoke-combined-repo-ruleset') || null
+ } catch { return null }
+ }, { desc: 'repo ruleset to be created on combined-settings-repo', timeout: 90000 })
+ assert(repoRuleset !== null, '14a: repo-level ruleset created on combined-settings-repo')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
+ // 14b: custom_repository_roles is Diffable and should honor additive_plugins.
+ // A role created outside safe-settings must survive a settings.yml sync that
+ // manages a different role while additive mode is enabled.
+ {
+ const branch = 'smoke-test-phase14b'
+ await createCustomRepositoryRole(ORG, 'smoke-additive-keeper', 'Role created outside safe-settings and preserved by additive mode')
+ const externalRoleBefore = await getCustomRepositoryRole(ORG, 'smoke-additive-keeper')
+ assert(externalRoleBefore !== null, '14b: external custom repository role exists before additive sync')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CRR_ADDITIVE, branch, '14b: enable additive custom repository roles')
+
+ const pr = await createPR(ORG, ADMIN_REPO, '14b: additive custom repository roles', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '14b: NOP check run completed')
+ if (checkRun) {
+ assert(checkRun.conclusion === 'success', `14b: NOP check run is success (got: ${checkRun.conclusion})`)
+ const crOutput = checkRun.output && (checkRun.output.summary || '')
+ assert(/additive|suppress/i.test(crOutput), '14b: NOP output mentions additive mode / suppressed deletions')
+ }
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const externalRoleAfter = await poll(async () => {
+ return await getCustomRepositoryRole(ORG, 'smoke-additive-keeper')
+ }, { desc: 'external custom repository role to remain after additive sync', timeout: 60000 })
+ assert(externalRoleAfter !== null, '14b: external custom repository role preserved by additive_plugins')
+
+ const managedRole = await poll(async () => {
+ return await getCustomRepositoryRole(ORG, 'security-engineer')
+ }, { desc: 'managed custom repository role to exist after additive sync', timeout: 60000 })
+ assert(managedRole !== null, '14b: managed custom repository role still created')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+}
+
async function teardown () {
logPhase('Phase 9: Teardown')
@@ -1796,11 +2284,11 @@ async function teardown () {
for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) }
log('Deleting custom repository role...')
- try {
- const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org: ORG })
- const secRole = (data.custom_roles || []).find(r => r.name === 'security-engineer')
- if (secRole) await octokit.request('DELETE /orgs/{org}/custom-repository-roles/{role_id}', { org: ORG, role_id: secRole.id })
- } catch { /* ok */ }
+ try { await deleteCustomRepositoryRole(ORG, 'security-engineer') } catch { /* ok */ }
+ try { await deleteCustomRepositoryRole(ORG, 'smoke-additive-keeper') } catch { /* ok */ }
+ try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-managed') } catch { /* ok */ }
+ try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-external') } catch { /* ok */ }
+ try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-disabled') } catch { /* ok */ }
log('Deleting org rulesets...')
try {
@@ -1808,6 +2296,9 @@ async function teardown () {
const testRs = rs.find(r => r.name === 'test')
if (testRs) await octokit.request('DELETE /orgs/{org}/rulesets/{ruleset_id}', { org: ORG, ruleset_id: testRs.id })
} catch { /* ok */ }
+ try { await deleteOrgRuleset(ORG, 'smoke-ruleset-managed') } catch { /* ok */ }
+ try { await deleteOrgRuleset(ORG, 'smoke-ruleset-external') } catch { /* ok */ }
+ try { await deleteOrgRuleset(ORG, 'smoke-ruleset-disabled') } catch { /* ok */ }
log('Resetting admin repo settings...')
const defaultBranch = await getDefaultBranch()
@@ -1864,17 +2355,20 @@ async function main () {
['Phase 10: disable_plugins', phase10DisablePlugins],
['Phase 11: additive_plugins', phase11AdditivePlugins],
['Phase 12: custom_properties', phase12CustomProperties],
- ['Phase 13: variables', phase13Variables]
+ ['Phase 12: custom_repository_roles', phase12CustomRoles],
+ ['Phase 12: rulesets', phase12Rulesets],
+ ['Phase 13: variables', phase13Variables],
+ ['Phase 14: regressions', phase14RegressionCoverage]
]
// When --phase is given, only run setup (phase 0) + the requested phase(s).
// Phase labels start with "Phase N:" so we match on that prefix.
const phases = ONLY_PHASES !== null
? allPhases.filter(([label]) => {
- if (label.startsWith('Phase 0:')) return true
- const m = label.match(/^Phase (\d+)[:\s]/)
- return m !== null && ONLY_PHASES.has(parseInt(m[1], 10))
- })
+ if (label.startsWith('Phase 0:')) return true
+ const m = label.match(/^Phase (\d+)[:\s]/)
+ return m !== null && ONLY_PHASES.has(parseInt(m[1], 10))
+ })
: allPhases
if (ONLY_PHASES !== null && phases.length < 2) {
diff --git a/test/unit/lib/plugins/rulesets.test.js b/test/unit/lib/plugins/rulesets.test.js
index f15abd63f..b6e860090 100644
--- a/test/unit/lib/plugins/rulesets.test.js
+++ b/test/unit/lib/plugins/rulesets.test.js
@@ -88,8 +88,7 @@ describe('Rulesets', () => {
log.debug = jest.fn()
log.error = jest.fn()
- function configure (config, scope='repo') {
- const noop = false
+ function configure (config, scope = 'repo', noop = false) {
const errors = []
return new Rulesets(noop, github, { owner: 'jitran', repo: 'test' }, config, log, errors, scope)
}
@@ -106,14 +105,12 @@ describe('Rulesets', () => {
request: jest.fn().mockImplementation(() => Promise.resolve('request')),
}
- github.request.endpoint = {
- merge: jest.fn().mockReturnValue({
- method: 'GET',
- url: '/repos/jitran/test/rulesets',
- headers: version
- }
- )
- }
+ github.request.endpoint = jest.fn().mockImplementation((route, body) => ({ url: route, body }))
+ github.request.endpoint.merge = jest.fn().mockReturnValue({
+ method: 'GET',
+ url: '/repos/jitran/test/rulesets',
+ headers: version
+ })
})
describe('sync', () => {
@@ -151,6 +148,35 @@ describe('Rulesets', () => {
)
})
})
+
+ it('in nop mode treats a missing repo as having no existing rulesets', async () => {
+ const notFound = new Error('Not Found')
+ notFound.status = 404
+ github.paginate = jest.fn().mockRejectedValue(notFound)
+
+ const plugin = configure(
+ [
+ generateRequestRuleset(
+ 1,
+ 'All branches',
+ repo_conditions,
+ [
+ { context: 'Status Check 1' }
+ ]
+ )
+ ],
+ 'repo',
+ true
+ )
+
+ const result = await plugin.sync()
+ const flat = result.flat()
+ const summary = flat.find(command => command.plugin === 'Rulesets' && command.action?.msg === 'Changes found')
+
+ expect(flat.some(command => command.type === 'ERROR')).toBe(false)
+ expect(summary.action.additions['0']).toEqual(expect.objectContaining({ name: 'All branches' }))
+ expect(summary.action.deletions).toBeUndefined()
+ })
})
describe('when {{EXTERNALLY_DEFINED}} is present in "required_status_checks" and no status checks exist in GitHub', () => {
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 5a20dc3fc..146601894 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -625,6 +625,20 @@ repository:
expect(settings.reevaluationDepth.get('r1')).toBe(1)
})
+ it('recurses once when a previously matched suborg source disappears', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = true
+ settings.subOrgConfigs = {}
+ settings.repoConfigs = { 'r1.yml': { custom_properties: [{ property_name: 'team', value: 'other' }] } }
+ jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ jest.spyOn(settings, 'getRepoConfigs').mockResolvedValue({ 'r1.yml': { custom_properties: [{ property_name: 'team', value: 'other' }] } })
+ const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue()
+ const pre = new Set(['.github/suborgs/old.yml'])
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, pre, { propertiesChanged: true })
+ expect(updateSpy).toHaveBeenCalledTimes(1)
+ expect(settings.reevaluationDepth.get('r1')).toBe(1)
+ })
+
it('respects MAX_REEVALUATION_DEPTH and logs a warning', async () => {
const settings = createSettings({})
settings.reevaluateOnChange = true
@@ -932,11 +946,31 @@ repository:
await settings.updateOrg()
expect(ctor).not.toHaveBeenCalled()
})
+
+ it('23. org custom_repository_roles receives additive=true when listed in additive_plugins', async () => {
+ const instances = []
+ const ctor = jest.fn().mockImplementation(function () {
+ this.sync = jest.fn().mockResolvedValue([])
+ instances.push(this)
+ })
+ Settings.PLUGINS.custom_repository_roles = ctor
+
+ const settings = createSettings({
+ additive_plugins: ['custom_repository_roles'],
+ custom_repository_roles: [{ name: 'sec' }]
+ })
+ settings.subOrgConfigs = {}
+ settings.repoConfigs = {}
+ await settings.updateOrg()
+
+ expect(instances).toHaveLength(1)
+ expect(instances[0].additive).toBe(true)
+ })
})
// ── updateRepos integration ──────────────────────────────────────────
describe('updateRepos integration', () => {
- it('23. org disable repository → RepoPlugin not instantiated', async () => {
+ it('24. org disable repository → RepoPlugin not instantiated', async () => {
const repoSync = jest.fn().mockResolvedValue([])
const repoCtor = jest.fn().mockImplementation(() => ({ sync: repoSync, renamed: false, created: false }))
Settings.PLUGINS.repository = repoCtor
@@ -1022,6 +1056,39 @@ repository:
expect(msgs.some(m => /labels/.test(m))).toBe(true)
expect(msgs.some(m => /teams/.test(m))).toBe(true)
})
+
+ it('28. base-config filtering preserves org-rulesets informational NopCommands', async () => {
+ stubContext.payload.repository = { owner: { login: 'test' }, name: 'safe-settings' }
+ stubContext.payload.check_run = { id: 123, check_suite: { pull_requests: [{ number: 456 }] } }
+ stubContext.octokit.checks = { update: jest.fn().mockResolvedValue({}) }
+ stubContext.octokit.issues = { createComment: jest.fn().mockResolvedValue({}) }
+
+ const settings = new Settings(true, stubContext, mockRepo, {
+ rulesets: [{ name: 'managed', enforcement: 'disabled' }]
+ }, mockRef)
+ settings.baseConfig = {
+ rulesets: [{ name: 'managed', enforcement: 'active' }]
+ }
+ settings.results = [{
+ type: 'INFO',
+ plugin: 'Rulesets',
+ repo: 'test (org)',
+ endpoint: '',
+ action: {
+ msg: 'Additive mode active: 1 deletion(s) suppressed by additive_plugins',
+ additions: null,
+ modifications: null,
+ deletions: null
+ }
+ }]
+
+ await settings.handleResults()
+
+ expect(stubContext.octokit.checks.update).toHaveBeenCalled()
+ const summary = stubContext.octokit.checks.update.mock.calls[0][0].output.summary
+ expect(summary).toMatch(/Informational messages/)
+ expect(summary).toMatch(/suppressed by additive_plugins/)
+ })
})
})
@@ -1138,6 +1205,22 @@ repository:
// ── updateRepos integration: additive flag threading ─────────────────
describe('updateRepos integration: additive flag', () => {
+ it('processes changed repo configs that were not returned by the installation repository list', async () => {
+ const settings = createSettings({ restrictedRepos: {} })
+ const updateReposSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue([])
+
+ settings.processedRepoNames = new Set(['existing-repo'])
+
+ await settings.updateChangedRepoConfigs([
+ { owner: 'test', repo: 'existing-repo' },
+ { owner: 'test', repo: 'new-repo' },
+ { owner: 'test', repo: 'new-repo' }
+ ])
+
+ expect(updateReposSpy).toHaveBeenCalledTimes(1)
+ expect(updateReposSpy).toHaveBeenCalledWith({ owner: 'test', repo: 'new-repo' })
+ })
+
it('39. plugin listed in additive_plugins has additive=true set before sync()', async () => {
const instances = []
const syncMock = jest.fn().mockResolvedValue([])
From b250312d2a2e164e29c3c9848d8f73ae32c8bb26 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 17 Jun 2026 01:22:14 -0400
Subject: [PATCH 22/67] fix: enhance ruleset handling in MergeDeep and add
tests for required_reviewers drift detection
---
lib/mergeDeep.js | 36 ++-
smoke-test.js | 149 +++++++++-
test/unit/lib/plugins/rulesets.test.js | 373 +++++++++++++++++++++++++
3 files changed, 545 insertions(+), 13 deletions(-)
diff --git a/lib/mergeDeep.js b/lib/mergeDeep.js
index ab278e5c2..171cd36e4 100644
--- a/lib/mergeDeep.js
+++ b/lib/mergeDeep.js
@@ -92,8 +92,8 @@ class MergeDeep {
// So any property in the target that is not in the source is not treated as a deletion
for (const key in source) {
// Skip prototype pollution vectors
- if (key === "__proto__" || key === "constructor") {
- continue;
+ if (key === '__proto__' || key === 'constructor') {
+ continue
}
// Logic specific for Github
// API response includes urls for resources, or other ignorable fields; we can ignore them
@@ -201,8 +201,10 @@ class MergeDeep {
} else {
// Not visited yet
const id = GET_NAME_USERNAME_PROPERTY(a)
- if (id) {
- visited[id] = a
+ // Use identifying property (name, username, etc.) or fall back to JSON representation for objects without named properties
+ const visitedId = id || JSON.stringify(a)
+ if (!visited[visitedId]) {
+ visited[visitedId] = a
}
}
} else {
@@ -222,7 +224,11 @@ class MergeDeep {
// Elements that are not in target are additions
additions[key] = combined.filter(item => {
if (this.isObjectNotArray(item)) {
- return !target.some(targetItem => GET_NAME_USERNAME_PROPERTY(item) === GET_NAME_USERNAME_PROPERTY(targetItem))
+ const itemId = GET_NAME_USERNAME_PROPERTY(item) || JSON.stringify(item)
+ return !target.some(targetItem => {
+ const targetId = GET_NAME_USERNAME_PROPERTY(targetItem) || JSON.stringify(targetItem)
+ return itemId === targetId
+ })
} else {
return !target.includes(item)
}
@@ -233,7 +239,11 @@ class MergeDeep {
// Elements that not in source are deletions
deletions[key] = combined.filter(item => {
if (this.isObjectNotArray(item)) {
- return !source.some(sourceItem => GET_NAME_USERNAME_PROPERTY(item) === GET_NAME_USERNAME_PROPERTY(sourceItem))
+ const itemId = GET_NAME_USERNAME_PROPERTY(item) || JSON.stringify(item)
+ return !source.some(sourceItem => {
+ const sourceId = GET_NAME_USERNAME_PROPERTY(sourceItem) || JSON.stringify(sourceItem)
+ return itemId === sourceId
+ })
} else {
return !source.includes(item)
}
@@ -243,13 +253,15 @@ class MergeDeep {
compareDeepIfVisited (additions, modifications, deletions, a, visited) {
const id = GET_NAME_USERNAME_PROPERTY(a)
- if (visited[id]) {
+ // Use identifying property or fall back to JSON representation for objects without named properties
+ const visitedId = id || JSON.stringify(a)
+ if (visited[visitedId]) {
// Common array in target and source
modifications.push({})
additions.push({})
deletions.push({})
- if (visited[id]) {
- this.compareDeep(a, visited[id], additions[additions.length - 1], modifications[modifications.length - 1], deletions[deletions.length - 1])
+ if (visited[visitedId]) {
+ this.compareDeep(a, visited[visitedId], additions[additions.length - 1], modifications[modifications.length - 1], deletions[deletions.length - 1])
}
// Any addtions for the matching key must be moved to modifications
const lastAddition = additions[additions.length - 1]
@@ -270,12 +282,12 @@ class MergeDeep {
}
// Add name attribute to the modifications to make it look better ; it won't be added otherwise as it would be the same
if (!this.isEmpty(modifications[modifications.length - 1])) {
- if (visited[id]) {
+ if (visited[visitedId]) {
modifications[modifications.length - 1][NAME_USERNAME_PROPERTY(a)] = id
}
}
- if (visited[id]) {
- delete visited[id]
+ if (visited[visitedId]) {
+ delete visited[visitedId]
}
return true
}
diff --git a/smoke-test.js b/smoke-test.js
index 3373a2c39..ae936f7ac 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -328,6 +328,13 @@ async function getRepoRuleset (owner, repo, name) {
} catch { return null }
}
+async function getRepoRulesetDetails (owner, repo, rulesetId) {
+ try {
+ const { data } = await octokit.request('GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', { owner, repo, ruleset_id: rulesetId })
+ return data
+ } catch { return null }
+}
+
async function setRepoCustomProperty (owner, repo, propertyName, value) {
await octokit.request('PATCH /repos/{owner}/{repo}/properties/values', {
owner,
@@ -2309,6 +2316,145 @@ async function teardown () {
log('Teardown complete')
}
+async function phase15RulesetArrayDrift () {
+ logPhase('Phase 15: Drift remediation - Ruleset array fields (bypass_actors, rules, required_reviewers)')
+
+ if (!GH_TOKEN) throw new Error('GH_TOKEN env var is required for drift tests (set to a fine-grained PAT)')
+
+ // ── 15a: Remove bypass_actors from "synk" ruleset ──────────────────────────
+ // The test repo "synk" ruleset has bypass_actors configured.
+ // Manually empty bypass_actors → safe-settings should detect and restore.
+ {
+ log('15a: Manually emptying bypass_actors on "synk" ruleset (as user)...')
+ const synkRuleset = await getRepoRuleset(ORG, 'test', 'synk')
+ if (!synkRuleset) {
+ logFail('15a: Could not find "synk" ruleset on test repo — was Phase 1 run?')
+ } else {
+ const fullRuleset = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id)
+ if (!fullRuleset) {
+ logFail('15a: Could not fetch ruleset details')
+ } else {
+ const body = JSON.stringify({ ...fullRuleset, bypass_actors: [] })
+ try {
+ execSync(`gh api /repos/${ORG}/test/rulesets/${synkRuleset.id} --method PUT --input -`, {
+ encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe']
+ })
+ log('15a: bypass_actors emptied on "synk" ruleset')
+ } catch (e) { logFail(`15a: Could not modify ruleset: ${e.message}`) }
+
+ log('Waiting for safe-settings to remediate...')
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const restored = await poll(async () => {
+ try {
+ const data = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id)
+ return (data && data.bypass_actors && data.bypass_actors.length > 0) ? data : null
+ } catch { return null }
+ }, { desc: 'bypass_actors to be restored on "synk" ruleset', timeout: 90000 })
+
+ assert(restored !== null, '15a: bypass_actors restored after manual removal (drift detected)')
+ if (restored) {
+ assert(
+ restored.bypass_actors.some(a => a.actor_type === 'OrganizationAdmin'),
+ '15a: OrganizationAdmin bypass actor is present after restoration'
+ )
+ }
+ }
+ }
+ }
+
+ // ── 15b: Add out-of-band rule to "synk" ruleset ────────────────────────────
+ // Add an extra rule not in the YAML config; safe-settings should remove it.
+ {
+ log('15b: Adding out-of-band "non_fast_forward" rule to "synk" ruleset (as user)...')
+ const synkRuleset = await getRepoRuleset(ORG, 'test', 'synk')
+ if (!synkRuleset) {
+ logFail('15b: Could not find "synk" ruleset on test repo')
+ } else {
+ const fullRuleset = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id)
+ if (!fullRuleset) {
+ logFail('15b: Could not fetch ruleset details')
+ } else {
+ const rules = [...(fullRuleset.rules || []), { type: 'non_fast_forward' }]
+ const body = JSON.stringify({ rules })
+ try {
+ execSync(`gh api /repos/${ORG}/test/rulesets/${synkRuleset.id} --method PUT --input -`, {
+ encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe']
+ })
+ log('15b: Added out-of-band "non_fast_forward" rule to "synk" ruleset')
+ } catch (e) { logFail(`15b: Could not modify ruleset: ${e.message}`) }
+
+ log('Waiting for safe-settings to remediate...')
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const reverted = await poll(async () => {
+ try {
+ const data = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id)
+ const hasExtraRule = data && (data.rules || []).some(r => r.type === 'non_fast_forward')
+ return hasExtraRule ? null : data
+ } catch { return null }
+ }, { desc: 'out-of-band rule to be removed from "synk" ruleset', timeout: 90000 })
+
+ assert(reverted !== null, '15b: out-of-band "non_fast_forward" rule removed from "synk" ruleset (drift detected)')
+ }
+ }
+ }
+
+ // ── 15c: Remove required_reviewers from suborg ruleset pull_request rule ───
+ // This test runs only if the suborg "Protect release and production branches"
+ // ruleset is present (requires Phase 5 to have run first).
+ {
+ log('15c: Checking for suborg "Protect release and production branches" ruleset on test repo...')
+ const suborgRuleset = await getRepoRuleset(ORG, 'test', 'Protect release and production branches')
+ if (!suborgRuleset) {
+ log('15c: Suborg ruleset not found — skipping required_reviewers drift test (run Phase 5 first)')
+ } else {
+ const fullRuleset = await getRepoRulesetDetails(ORG, 'test', suborgRuleset.id)
+ if (!fullRuleset) {
+ logFail('15c: Could not fetch suborg ruleset details')
+ } else {
+ const prRule = (fullRuleset.rules || []).find(r => r.type === 'pull_request')
+ const hasRequiredReviewers = prRule && prRule.parameters &&
+ Array.isArray(prRule.parameters.required_reviewers) &&
+ prRule.parameters.required_reviewers.length > 0
+
+ if (!hasRequiredReviewers) {
+ log('15c: Suborg ruleset pull_request rule has no required_reviewers — skipping 15c')
+ } else {
+ log('15c: Manually emptying required_reviewers in pull_request rule (as user)...')
+ const rules = (fullRuleset.rules || []).map(rule => {
+ if (rule.type === 'pull_request') {
+ return { ...rule, parameters: { ...(rule.parameters || {}), required_reviewers: [] } }
+ }
+ return rule
+ })
+ const body = JSON.stringify({ rules })
+ try {
+ execSync(`gh api /repos/${ORG}/test/rulesets/${suborgRuleset.id} --method PUT --input -`, {
+ encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe']
+ })
+ log('15c: required_reviewers emptied in pull_request rule')
+ } catch (e) { logFail(`15c: Could not modify ruleset: ${e.message}`) }
+
+ log('Waiting for safe-settings to remediate...')
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const restored = await poll(async () => {
+ try {
+ const data = await getRepoRulesetDetails(ORG, 'test', suborgRuleset.id)
+ const pr = data && (data.rules || []).find(r => r.type === 'pull_request')
+ const reviewers = pr && pr.parameters && pr.parameters.required_reviewers
+ return (Array.isArray(reviewers) && reviewers.length > 0) ? data : null
+ } catch { return null }
+ }, { desc: 'required_reviewers to be restored in pull_request rule', timeout: 90000 })
+
+ assert(restored !== null, '15c: required_reviewers restored after manual removal (drift detected)')
+ }
+ }
+ }
+ }
+}
+
// ─── Main ────────────────────────────────────────────────────────────────────
async function main () {
@@ -2358,7 +2504,8 @@ async function main () {
['Phase 12: custom_repository_roles', phase12CustomRoles],
['Phase 12: rulesets', phase12Rulesets],
['Phase 13: variables', phase13Variables],
- ['Phase 14: regressions', phase14RegressionCoverage]
+ ['Phase 14: regressions', phase14RegressionCoverage],
+ ['Phase 15: Ruleset array drift', phase15RulesetArrayDrift]
]
// When --phase is given, only run setup (phase 0) + the requested phase(s).
diff --git a/test/unit/lib/plugins/rulesets.test.js b/test/unit/lib/plugins/rulesets.test.js
index b6e860090..15319cff0 100644
--- a/test/unit/lib/plugins/rulesets.test.js
+++ b/test/unit/lib/plugins/rulesets.test.js
@@ -445,4 +445,377 @@ describe('Rulesets', () => {
})
})
})
+
+ describe('changed() method with required_reviewers', () => {
+ it('detects when required_reviewers array changes from populated to empty', () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+
+ const plugin = configure([
+ {
+ name: 'Protect release branches',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/release/*'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ required_approving_review_count: 1,
+ dismiss_stale_reviews_on_push: false,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_review_thread_resolution: false,
+ allowed_merge_methods: ['merge', 'squash', 'rebase'],
+ required_reviewers: [
+ {
+ minimum_approvals: 1,
+ file_patterns: ['*.js'],
+ reviewer: {
+ id: 11721733,
+ type: 'Team'
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ])
+
+ // GitHub state after manual removal of required_reviewers
+ const existingRuleset = {
+ name: 'Protect release branches',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/release/*'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ required_approving_review_count: 1,
+ dismiss_stale_reviews_on_push: false,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_review_thread_resolution: false,
+ allowed_merge_methods: ['merge', 'squash', 'rebase'],
+ required_reviewers: [] // Empty after manual removal
+ }
+ }
+ ]
+ }
+
+ // YAML config (what safe-settings expects)
+ const attrs = plugin.rulesets[0]
+
+ // The changed() method should detect this difference
+ const result = plugin.changed(existingRuleset, attrs)
+ expect(result).toBe(true)
+ })
+
+ it('detects when bypass_actors array changes from populated to empty', () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+
+ const plugin = configure([
+ {
+ name: 'Main protection',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ bypass_actors: [
+ {
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'always'
+ }
+ ],
+ rules: [
+ {
+ type: 'creation'
+ }
+ ]
+ }
+ ])
+
+ // GitHub state after manual removal of bypass_actors
+ const existingRuleset = {
+ name: 'Main protection',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ bypass_actors: [], // Empty after manual removal
+ rules: [
+ {
+ type: 'creation'
+ }
+ ]
+ }
+
+ const attrs = plugin.rulesets[0]
+ const result = plugin.changed(existingRuleset, attrs)
+ expect(result).toBe(true)
+ })
+
+ it('detects when workflows array changes from populated to empty', () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+
+ const plugin = configure([
+ {
+ name: 'Workflow protection',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'workflows',
+ parameters: {
+ do_not_enforce_on_create: false,
+ workflows: [
+ {
+ path: '.github/workflows/test.yml',
+ repository_id: 123456
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ])
+
+ // GitHub state after manual removal of workflows
+ const existingRuleset = {
+ name: 'Workflow protection',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'workflows',
+ parameters: {
+ do_not_enforce_on_create: false,
+ workflows: [] // Empty after manual removal
+ }
+ }
+ ]
+ }
+
+ const attrs = plugin.rulesets[0]
+ const result = plugin.changed(existingRuleset, attrs)
+ expect(result).toBe(true)
+ })
+
+ it('detects when rules array has item added out-of-band', () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+
+ const plugin = configure([
+ {
+ name: 'Branch rules',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'creation'
+ }
+ ]
+ }
+ ])
+
+ // GitHub state where an extra rule was added manually
+ const existingRuleset = {
+ name: 'Branch rules',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'creation'
+ },
+ {
+ type: 'deletion' // Extra rule added out-of-band
+ }
+ ]
+ }
+
+ const attrs = plugin.rulesets[0]
+ const result = plugin.changed(existingRuleset, attrs)
+ expect(result).toBe(true)
+ })
+
+ it('detects when required_reviewers item is modified with different file patterns', () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+
+ const plugin = configure([
+ {
+ name: 'Code review',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ required_approving_review_count: 1,
+ dismiss_stale_reviews_on_push: false,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_review_thread_resolution: false,
+ allowed_merge_methods: ['merge'],
+ required_reviewers: [
+ {
+ minimum_approvals: 1,
+ file_patterns: ['*.js', '*.ts'],
+ reviewer: {
+ id: 999,
+ type: 'Team'
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ])
+
+ // GitHub state where file patterns were manually changed
+ const existingRuleset = {
+ name: 'Code review',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ required_approving_review_count: 1,
+ dismiss_stale_reviews_on_push: false,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_review_thread_resolution: false,
+ allowed_merge_methods: ['merge'],
+ required_reviewers: [
+ {
+ minimum_approvals: 1,
+ file_patterns: ['*.py'], // Different patterns
+ reviewer: {
+ id: 999,
+ type: 'Team'
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+
+ const attrs = plugin.rulesets[0]
+ const result = plugin.changed(existingRuleset, attrs)
+ expect(result).toBe(true)
+ })
+
+ it('detects when bypass_actors item is modified with different bypass_mode', () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+
+ const plugin = configure([
+ {
+ name: 'Bypass config',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ bypass_actors: [
+ {
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'always'
+ }
+ ],
+ rules: [
+ {
+ type: 'creation'
+ }
+ ]
+ }
+ ])
+
+ // GitHub state where bypass_mode was manually changed
+ const existingRuleset = {
+ name: 'Bypass config',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: {
+ ref_name: {
+ include: ['refs/heads/main'],
+ exclude: []
+ }
+ },
+ bypass_actors: [
+ {
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'pull_request' // Changed from 'always'
+ }
+ ],
+ rules: [
+ {
+ type: 'creation'
+ }
+ ]
+ }
+
+ const attrs = plugin.rulesets[0]
+ const result = plugin.changed(existingRuleset, attrs)
+ expect(result).toBe(true)
+ })
+ })
})
From 2bfc2e869afe5dca6325fe10c077f55b836b5063 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 17 Jun 2026 18:48:44 -0400
Subject: [PATCH 23/67] test: add ruleset comparison tests for
required_reviewers and unnamed object array key reordering
---
lib/mergeDeep.js | 103 ++++++++++++++++++-----
test/unit/lib/mergeDeep.test.js | 145 ++++++++++++++++++++++++++++++++
2 files changed, 228 insertions(+), 20 deletions(-)
diff --git a/lib/mergeDeep.js b/lib/mergeDeep.js
index 171cd36e4..04b8370af 100644
--- a/lib/mergeDeep.js
+++ b/lib/mergeDeep.js
@@ -5,6 +5,44 @@ const NAME_FIELDS = ['name', 'username', 'actor_id', 'login', 'type', 'key_prefi
const NAME_USERNAME_PROPERTY = item => NAME_FIELDS.find(prop => Object.prototype.hasOwnProperty.call(item, prop))
const GET_NAME_USERNAME_PROPERTY = item => { if (NAME_USERNAME_PROPERTY(item)) return item[NAME_USERNAME_PROPERTY(item)] }
+// Fields within a rule's `parameters` that are managed/defaulted by the GitHub API.
+// They should not be treated as user-driven deletions when omitted from config.
+const PARAM_DELETION_IGNORE = ['allowed_merge_methods']
+
+// Order-insensitive JSON serialization used as a fallback identity for array
+// elements that have no named identifying field (e.g. `code_scanning_tools`).
+// The GitHub API often returns object keys in a different order than config, so
+// a plain JSON.stringify would treat semantically-equal items as different and
+// produce spurious add/delete churn. Sorting keys recursively avoids that.
+const stableStringify = value => {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(',')}]`
+ }
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
+ }
+ return JSON.stringify(value)
+}
+
+// Compute the identity value for an array element so the same logical item in
+// `source` (config) and `target` (GitHub API) can be paired during comparison.
+// Returns the raw identifying value (so a bare string shorthand like 'developers'
+// still matches an object like { name: 'developers' }). Special-cases bypass
+// actors: GitHub returns `actor_id: null` for role-based actor types such as
+// `OrganizationAdmin`, so we key those on `actor_type` to avoid spurious
+// add/delete churn when config supplies an explicit id.
+const getItemIdentity = item => {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined
+ if (Object.prototype.hasOwnProperty.call(item, 'actor_type') &&
+ Object.prototype.hasOwnProperty.call(item, 'bypass_mode')) {
+ if (item.actor_id === null || item.actor_id === undefined || item.actor_type === 'OrganizationAdmin') {
+ return item.actor_type
+ }
+ return item.actor_id
+ }
+ return GET_NAME_USERNAME_PROPERTY(item)
+}
+
class MergeDeep {
constructor (log, github, ignorableFields = [], configvalidators = {}, overridevalidators = {}) {
this.log = log
@@ -53,7 +91,7 @@ class MergeDeep {
* @param {*} deletions aggregated so far
* @returns object with additions, modifications, and deletions
*/
- compareDeep (t, s, additions, modifications, deletions) {
+ compareDeep (t, s, additions, modifications, deletions, parentKey) {
// Preemtively return if the source is not an object or array
if (!this.isObject(s)) {
return { additions, modifications, deletions, hasChanges: s !== t }
@@ -126,18 +164,25 @@ class MergeDeep {
this.processArrays(key, sourceValue, targetValue, deletions, additions, modifications)
} else {
// recursively compare the objects until we reach a primitive
- this.compareDeep(targetValue, sourceValue, additions[key], modifications[key], deletions[key])
+ this.compareDeep(targetValue, sourceValue, additions[key], modifications[key], deletions[key], key)
this.validateOverride(key, targetValue, sourceValue)
}
} else { // The entry is a simple primitive
if (targetValue !== sourceValue) {
- // Note: source[key] cannot be undefined here since we are iterating on source keys
- // so we don't need to check for that.
- // The entries are different. It is an addition
- modifications[key] = sourceValue
- // retroactively add `name` or `username` to the modifications
- // Since those are the only fields that can be used to identify the resource
- this.addIdentifyingAttribute(source, key, modifications)
+ // GitHub returns `actor_id: null` for role-based bypass actor types
+ // (e.g. OrganizationAdmin) regardless of the id supplied in config.
+ // Don't treat that placeholder mismatch as a modification.
+ if (key === 'actor_id' && (targetValue === null || sourceValue === null)) {
+ // treat as equal
+ } else {
+ // Note: source[key] cannot be undefined here since we are iterating on source keys
+ // so we don't need to check for that.
+ // The entries are different. It is an addition
+ modifications[key] = sourceValue
+ // retroactively add `name` or `username` to the modifications
+ // Since those are the only fields that can be used to identify the resource
+ this.addIdentifyingAttribute(source, key, modifications)
+ }
} else {
// The entry is the same in both objects
}
@@ -147,6 +192,22 @@ class MergeDeep {
additions = this.removeEmptyAndNulls(additions, key)
deletions = this.removeEmptyAndNulls(deletions, key)
}
+
+ // Detect deletions for config-meaningful nested objects (e.g. a rule's
+ // `parameters`). The GitHub API is additive for top-level metadata, so we
+ // only do this for known config subtrees to avoid flagging server-managed
+ // or metadata fields (timestamps, _links, source_type, etc.) as deletions.
+ if (parentKey === 'parameters' && this.isObjectNotArray(target) && this.isObjectNotArray(source)) {
+ for (const key in target) {
+ if (key === '__proto__' || key === 'constructor') continue
+ if (key.indexOf('url') >= 0 || this.ignorableFields.indexOf(key) >= 0) continue
+ if (PARAM_DELETION_IGNORE.indexOf(key) >= 0) continue
+ if (!(key in source)) {
+ // Present in GitHub but removed from config => a deletion
+ deletions[key] = target[key]
+ }
+ }
+ }
// Unwind the topleve array from the object
if (firstInvocation) {
if (additions.__array) {
@@ -184,7 +245,8 @@ class MergeDeep {
if (source.length < target.length) {
const dels = target.filter(item => {
if (this.isObjectNotArray(item)) {
- return !source.some(sourceItem => GET_NAME_USERNAME_PROPERTY(item) === GET_NAME_USERNAME_PROPERTY(sourceItem))
+ const itemId = getItemIdentity(item) || stableStringify(item)
+ return !source.some(sourceItem => (getItemIdentity(sourceItem) || stableStringify(sourceItem)) === itemId)
} else {
return !source.includes(item)
}
@@ -200,9 +262,8 @@ class MergeDeep {
continue
} else {
// Not visited yet
- const id = GET_NAME_USERNAME_PROPERTY(a)
- // Use identifying property (name, username, etc.) or fall back to JSON representation for objects without named properties
- const visitedId = id || JSON.stringify(a)
+ // Use identifying property (name, username, actor_type, etc.) or fall back to JSON representation for objects without named properties
+ const visitedId = getItemIdentity(a) || stableStringify(a)
if (!visited[visitedId]) {
visited[visitedId] = a
}
@@ -224,9 +285,9 @@ class MergeDeep {
// Elements that are not in target are additions
additions[key] = combined.filter(item => {
if (this.isObjectNotArray(item)) {
- const itemId = GET_NAME_USERNAME_PROPERTY(item) || JSON.stringify(item)
+ const itemId = getItemIdentity(item) || stableStringify(item)
return !target.some(targetItem => {
- const targetId = GET_NAME_USERNAME_PROPERTY(targetItem) || JSON.stringify(targetItem)
+ const targetId = getItemIdentity(targetItem) || stableStringify(targetItem)
return itemId === targetId
})
} else {
@@ -239,9 +300,9 @@ class MergeDeep {
// Elements that not in source are deletions
deletions[key] = combined.filter(item => {
if (this.isObjectNotArray(item)) {
- const itemId = GET_NAME_USERNAME_PROPERTY(item) || JSON.stringify(item)
+ const itemId = getItemIdentity(item) || stableStringify(item)
return !source.some(sourceItem => {
- const sourceId = GET_NAME_USERNAME_PROPERTY(sourceItem) || JSON.stringify(sourceItem)
+ const sourceId = getItemIdentity(sourceItem) || stableStringify(sourceItem)
return itemId === sourceId
})
} else {
@@ -252,9 +313,8 @@ class MergeDeep {
}
compareDeepIfVisited (additions, modifications, deletions, a, visited) {
- const id = GET_NAME_USERNAME_PROPERTY(a)
// Use identifying property or fall back to JSON representation for objects without named properties
- const visitedId = id || JSON.stringify(a)
+ const visitedId = getItemIdentity(a) || stableStringify(a)
if (visited[visitedId]) {
// Common array in target and source
modifications.push({})
@@ -283,7 +343,10 @@ class MergeDeep {
// Add name attribute to the modifications to make it look better ; it won't be added otherwise as it would be the same
if (!this.isEmpty(modifications[modifications.length - 1])) {
if (visited[visitedId]) {
- modifications[modifications.length - 1][NAME_USERNAME_PROPERTY(a)] = id
+ const displayProp = NAME_USERNAME_PROPERTY(a)
+ if (displayProp) {
+ modifications[modifications.length - 1][displayProp] = a[displayProp]
+ }
}
}
if (visited[visitedId]) {
diff --git a/test/unit/lib/mergeDeep.test.js b/test/unit/lib/mergeDeep.test.js
index dd8bd60ba..0d6506781 100644
--- a/test/unit/lib/mergeDeep.test.js
+++ b/test/unit/lib/mergeDeep.test.js
@@ -1314,6 +1314,151 @@ entries:
// console.log(`diffs ${JSON.stringify(merged, null, 2)}`)
})
+ it('Ruleset Compare detects required_reviewers removal without bypass_actors churn', () => {
+ // Existing ruleset in GitHub: has required_reviewers and a server-defaulted
+ // allowed_merge_methods. GitHub returns actor_id: null for the OrganizationAdmin
+ // bypass actor.
+ const target = {
+ id: 12345,
+ name: 'synk',
+ target: 'branch',
+ source_type: 'Repository',
+ source: 'decyjphr-org/test',
+ enforcement: 'active',
+ node_id: 'RRS_xxx',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-02T00:00:00Z',
+ current_user_can_bypass: 'always',
+ _links: { self: { href: 'https://api.github.com/repos/x/y/rulesets/12345' } },
+ bypass_actors: [
+ { actor_id: null, actor_type: 'OrganizationAdmin', bypass_mode: 'pull_request' }
+ ],
+ conditions: { ref_name: { exclude: [], include: ['~DEFAULT_BRANCH'] } },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ dismiss_stale_reviews_on_push: true,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_approving_review_count: 2,
+ required_review_thread_resolution: false,
+ required_reviewers: [
+ { minimum_approvals: 1, file_patterns: ['*.js'], reviewer: { id: 11721733, type: 'Team' } }
+ ],
+ allowed_merge_methods: ['merge', 'squash', 'rebase']
+ }
+ }
+ ]
+ }
+ // Config: required_reviewers removed, OrganizationAdmin bypass actor with explicit id.
+ const source = {
+ name: 'synk',
+ target: 'branch',
+ enforcement: 'active',
+ bypass_actors: [
+ { actor_id: 1, actor_type: 'OrganizationAdmin', bypass_mode: 'pull_request' }
+ ],
+ conditions: { ref_name: { exclude: [], include: ['~DEFAULT_BRANCH'] } },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ dismiss_stale_reviews_on_push: true,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_approving_review_count: 2,
+ required_review_thread_resolution: false
+ }
+ }
+ ]
+ }
+ const ignorableFields = []
+ const mockReturnGitHubContext = jest.fn().mockReturnValue({
+ request: () => {}
+ })
+ const mergeDeep = new MergeDeep(
+ log,
+ mockReturnGitHubContext,
+ ignorableFields
+ )
+ const merged = mergeDeep.compareDeep(target, source)
+
+ // The removal of required_reviewers must be detected as a change.
+ expect(merged.hasChanges).toBeTruthy()
+ expect(merged.deletions.rules[0].parameters.required_reviewers).toEqual([
+ { minimum_approvals: 1, file_patterns: ['*.js'], reviewer: { id: 11721733, type: 'Team' } }
+ ])
+ // The OrganizationAdmin bypass actor (actor_id 1 vs null) must NOT churn.
+ expect(merged.additions.bypass_actors).toBeUndefined()
+ expect(merged.deletions.bypass_actors).toBeUndefined()
+ // allowed_merge_methods is a server-managed default and must NOT be a deletion.
+ expect(merged.deletions.rules[0].parameters.allowed_merge_methods).toBeUndefined()
+ })
+
+ it('Ruleset Compare reports no change when unnamed object array keys are reordered', () => {
+ // code_scanning_tools elements are keyed by `tool` (not a NAME_FIELD), so they
+ // fall back to a stable identity. GitHub returns the object keys in a different
+ // order than config; this must NOT produce spurious add/modify/delete churn.
+ const target = {
+ id: 17806629,
+ name: 'Prevent merges when new SONAR alerts are introduced',
+ target: 'branch',
+ source_type: 'Repository',
+ source: 'decyjphr-emu/test',
+ enforcement: 'active',
+ node_id: 'RRS_xxx',
+ created_at: '2026-06-17T18:45:28.141Z',
+ updated_at: '2026-06-17T18:45:28.162Z',
+ current_user_can_bypass: 'always',
+ _links: { self: { href: 'https://x' }, html: { href: 'https://y' } },
+ bypass_actors: [
+ { actor_id: null, actor_type: 'OrganizationAdmin', bypass_mode: 'always' }
+ ],
+ conditions: { ref_name: { exclude: [], include: ['~DEFAULT_BRANCH'] } },
+ rules: [
+ {
+ type: 'code_scanning',
+ parameters: {
+ code_scanning_tools: [
+ { tool: 'Sonar', security_alerts_threshold: 'medium_or_higher', alerts_threshold: 'none' }
+ ]
+ }
+ }
+ ]
+ }
+ const source = {
+ name: 'Prevent merges when new SONAR alerts are introduced',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
+ bypass_actors: [
+ { actor_type: 'OrganizationAdmin', bypass_mode: 'always' }
+ ],
+ rules: [
+ {
+ type: 'code_scanning',
+ parameters: {
+ code_scanning_tools: [
+ { tool: 'Sonar', alerts_threshold: 'none', security_alerts_threshold: 'medium_or_higher' }
+ ]
+ }
+ }
+ ]
+ }
+ const ignorableFields = []
+ const mockReturnGitHubContext = jest.fn().mockReturnValue({
+ request: () => {}
+ })
+ const mergeDeep = new MergeDeep(
+ log,
+ mockReturnGitHubContext,
+ ignorableFields
+ )
+ const merged = mergeDeep.compareDeep(target, source)
+ expect(merged.hasChanges).toBeFalsy()
+ })
+
it('Ruleset Compare Works when required_status_checks change', () => {
const target = [
{
From 2d2f92f0bca195681691f44839e84c52b965fefb Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 10:26:29 -0400
Subject: [PATCH 24/67] feat: implement name-based resolution for ruleset
bypass actors and reviewers
---
README.md | 54 +++++++
docs/sample-settings/settings.yml | 28 ++++
lib/plugins/rulesets.js | 154 ++++++++++++++++++-
schema/dereferenced/settings.json | 29 +++-
script/build-schema | 43 ++++++
smoke-test.js | 188 +++++++++++++++++++++++-
test/unit/lib/plugins/rulesets.test.js | 196 +++++++++++++++++++++++++
7 files changed, 684 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index f1db36632..ff1f0237f 100644
--- a/README.md
+++ b/README.md
@@ -337,6 +337,60 @@ Notes:
> ⚠️ **Warning:**
When `{{EXTERNALLY_DEFINED}}` is removed from an existing branch protection rule or ruleset configuration, the status checks in the existing rules in GitHub will revert to the checks that are defined in safe-settings. From this point onwards, all status checks configured through the GitHub UI will be reverted back to the safe-settings configuration.
+#### Referencing ruleset bypass actors and reviewers by name
+
+Rulesets normally require numeric ids for `bypass_actors[].actor_id` and for the
+team in `required_reviewers[].reviewer.id`. To avoid looking these ids up, you
+can reference them by name and safe-settings resolves them to the correct id
+before applying the ruleset:
+
+- `bypass_actors[].name` — an alternative to `actor_id`. The value is resolved
+ based on `actor_type`:
+ - `Team` → team slug
+ - `User` → username
+ - `Integration` → GitHub App slug
+ - `RepositoryRole` → role name. Built-in roles (`read`, `triage`, `write`,
+ `maintain`, `admin`) are mapped automatically; any other name is looked up
+ among the organization's custom repository roles.
+- `required_reviewers[].reviewer.slug` — an alternative to `reviewer.id`, the
+ slug of the reviewing team.
+
+```yaml
+rulesets:
+ - name: Main protection
+ target: branch
+ enforcement: active
+ bypass_actors:
+ - name: my-team # resolved to actor_id
+ actor_type: Team
+ bypass_mode: always
+ - name: admin # built-in repository role
+ actor_type: RepositoryRole
+ bypass_mode: always
+ rules:
+ - type: pull_request
+ parameters:
+ required_approving_review_count: 1
+ dismiss_stale_reviews_on_push: false
+ require_code_owner_review: false
+ require_last_push_approval: false
+ required_review_thread_resolution: false
+ required_reviewers:
+ - minimum_approvals: 1
+ file_patterns: ["*.js"]
+ reviewer:
+ slug: my-reviewers-team # resolved to reviewer.id
+ type: Team
+```
+
+Notes:
+ - This is fully backward compatible. Existing policies that use `actor_id` /
+ `reviewer.id` continue to work unchanged, and numeric ids are never looked up.
+ - Provide either the name (`name` / `slug`) or the id (`actor_id` /
+ `reviewer.id`) for a given entry, not both. Specifying both is an error.
+ - If a name cannot be resolved to an id, the ruleset sync fails with a clear
+ error so the misconfiguration is surfaced rather than silently ignored.
+
#### Status checks inheritance across scopes
Refer to [Status checks](docs/status-checks.md).
diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml
index 06fe2fd50..85f50df9d 100644
--- a/docs/sample-settings/settings.yml
+++ b/docs/sample-settings/settings.yml
@@ -262,6 +262,23 @@ rulesets:
actor_type: Integration
bypass_mode: always
+ # Instead of looking up numeric ids, you can use the `name` field to
+ # reference an actor by name. safe-settings resolves it to `actor_id`
+ # based on `actor_type` before applying the ruleset:
+ # - Team -> team slug
+ # - User -> username
+ # - Integration -> GitHub App slug
+ # - RepositoryRole -> role name (built-in: read, triage, write,
+ # maintain, admin; or a custom role name)
+ # Provide either `name` or `actor_id`, not both.
+ - name: my-team
+ actor_type: Team
+ bypass_mode: always
+
+ - name: admin
+ actor_type: RepositoryRole
+ bypass_mode: always
+
conditions:
# Parameters for a repository ruleset ref name condition
ref_name:
@@ -322,6 +339,17 @@ rulesets:
# All conversations on code must be resolved before a pull
# request can be merged.
required_review_thread_resolution: true
+ # A collection of reviewers and the file patterns they must
+ # approve. Each reviewer is a team. Use `id` (team id) or, to
+ # avoid looking up the id, `slug` (team slug) which
+ # safe-settings resolves before applying the ruleset. Provide
+ # either `slug` or `id`, not both.
+ required_reviewers:
+ - minimum_approvals: 1
+ file_patterns: ["*.js"]
+ reviewer:
+ slug: my-reviewers-team
+ type: Team
# Choose which status checks must pass before branches can be merged
# into a branch that matches this rule. When enabled, commits must
diff --git a/lib/plugins/rulesets.js b/lib/plugins/rulesets.js
index c9be14647..e02383649 100644
--- a/lib/plugins/rulesets.js
+++ b/lib/plugins/rulesets.js
@@ -4,16 +4,29 @@ const MergeDeep = require('../mergeDeep')
const Overrides = require('./overrides')
const ignorableFields = []
const overrides = {
- 'required_status_checks': {
- 'action': 'delete',
- 'parents': 3,
- 'type': 'dict'
- },
+ required_status_checks: {
+ action: 'delete',
+ parents: 3,
+ type: 'dict'
+ }
}
const version = {
'X-GitHub-Api-Version': '2022-11-28'
}
+
+// GitHub's built-in (base) repository role IDs. These are not returned by the
+// custom-repository-roles API, so they are mapped statically here to allow
+// users to reference them by name in a ruleset's bypass_actors. Custom roles
+// are resolved dynamically via GET /orgs/{org}/custom-repository-roles.
+const BASE_REPOSITORY_ROLE_IDS = {
+ read: 1,
+ triage: 2,
+ write: 3,
+ maintain: 4,
+ admin: 5
+}
+
module.exports = class Rulesets extends Diffable {
constructor (nop, github, repo, entries, log, errors, scope) {
super(nop, github, repo, entries, log, errors)
@@ -23,6 +36,137 @@ module.exports = class Rulesets extends Diffable {
this.log = log
this.nop = nop
this.scope = scope || 'repo'
+ // Cache for name -> id lookups, scoped to a single sync() invocation.
+ this.idCache = new Map()
+ }
+
+ // Resolve human-friendly names to the numeric ids GitHub expects before the
+ // normal Diffable sync runs. This lets users define rulesets using a team
+ // slug, username, GitHub App slug, or repository role name instead of having
+ // to look up the corresponding id. Names are resolved in place and the helper
+ // attribute is removed so the payload matches what GitHub returns (which only
+ // contains ids), keeping compareDeep stable and backward compatible with
+ // policies that already use ids.
+ async sync () {
+ try {
+ await this.resolveNamesToIds()
+ } catch (e) {
+ return this.handleError(e)
+ }
+ return super.sync()
+ }
+
+ async resolveNamesToIds () {
+ if (!this.entries) return
+ this.idCache = new Map()
+ for (const ruleset of this.entries) {
+ if (Array.isArray(ruleset.bypass_actors)) {
+ for (const actor of ruleset.bypass_actors) {
+ await this.resolveBypassActor(actor)
+ }
+ }
+ const rules = Array.isArray(ruleset.rules) ? ruleset.rules : []
+ for (const rule of rules) {
+ const reviewers = rule && rule.parameters && rule.parameters.required_reviewers
+ if (Array.isArray(reviewers)) {
+ for (const entry of reviewers) {
+ await this.resolveReviewer(entry)
+ }
+ }
+ }
+ }
+ }
+
+ async resolveBypassActor (actor) {
+ if (!actor || actor.name === undefined || actor.name === null) return
+ if (actor.actor_id !== undefined && actor.actor_id !== null) {
+ throw new Error(`Ruleset bypass_actor cannot specify both 'name' ('${actor.name}') and 'actor_id' (${actor.actor_id}). Use one or the other.`)
+ }
+ actor.actor_id = await this.resolveActorId(actor.actor_type, actor.name)
+ delete actor.name
+ }
+
+ async resolveReviewer (entry) {
+ const reviewer = entry && entry.reviewer
+ if (!reviewer || reviewer.slug === undefined || reviewer.slug === null) return
+ if (reviewer.id !== undefined && reviewer.id !== null) {
+ throw new Error(`Ruleset required_reviewer cannot specify both 'slug' ('${reviewer.slug}') and 'id' (${reviewer.id}). Use one or the other.`)
+ }
+ reviewer.id = await this.resolveTeamId(reviewer.slug)
+ delete reviewer.slug
+ }
+
+ async resolveActorId (actorType, name) {
+ switch (actorType) {
+ case 'Team':
+ return this.resolveTeamId(name)
+ case 'User':
+ return this.resolveUserId(name)
+ case 'Integration':
+ return this.resolveIntegrationId(name)
+ case 'RepositoryRole':
+ return this.resolveRepositoryRoleId(name)
+ default:
+ throw new Error(`Cannot resolve 'name' '${name}' for actor_type '${actorType}'. Name resolution is only supported for Team, User, Integration, and RepositoryRole. Use 'actor_id' instead.`)
+ }
+ }
+
+ async cachedLookup (key, fn) {
+ if (this.idCache.has(key)) return this.idCache.get(key)
+ const value = await fn()
+ this.idCache.set(key, value)
+ return value
+ }
+
+ async resolveTeamId (slug) {
+ return this.cachedLookup(`Team:${slug}`, async () => {
+ try {
+ const res = await this.github.teams.getByName({ org: this.repo.owner, team_slug: slug })
+ return res.data.id
+ } catch (e) {
+ throw new Error(`Unable to resolve Team slug '${slug}' to an id in org '${this.repo.owner}': ${e.status || e.message}`)
+ }
+ })
+ }
+
+ async resolveUserId (username) {
+ return this.cachedLookup(`User:${username}`, async () => {
+ try {
+ const res = await this.github.request('GET /users/{username}', { username })
+ return res.data.id
+ } catch (e) {
+ throw new Error(`Unable to resolve User '${username}' to an id: ${e.status || e.message}`)
+ }
+ })
+ }
+
+ async resolveIntegrationId (slug) {
+ return this.cachedLookup(`Integration:${slug}`, async () => {
+ try {
+ const res = await this.github.request('GET /apps/{app_slug}', { app_slug: slug })
+ return res.data.id
+ } catch (e) {
+ throw new Error(`Unable to resolve Integration (GitHub App) slug '${slug}' to an id: ${e.status || e.message}`)
+ }
+ })
+ }
+
+ async resolveRepositoryRoleId (name) {
+ return this.cachedLookup(`RepositoryRole:${name}`, async () => {
+ const baseId = BASE_REPOSITORY_ROLE_IDS[String(name).toLowerCase()]
+ if (baseId !== undefined) return baseId
+ try {
+ const res = await this.github.request('GET /orgs/{org}/custom-repository-roles', { org: this.repo.owner })
+ const roles = (res.data && res.data.custom_roles) || []
+ const match = roles.find(role => role.name === name)
+ if (!match) {
+ throw new Error(`no custom repository role named '${name}' found in org '${this.repo.owner}'`)
+ }
+ return match.id
+ } catch (e) {
+ throw new Error(`Unable to resolve RepositoryRole '${name}' to an id: ${e.status || e.message}`)
+ }
+ })
}
// Find all Rulesets for this org
diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json
index 0899fd06e..d4955ad26 100644
--- a/schema/dereferenced/settings.json
+++ b/schema/dereferenced/settings.json
@@ -174,6 +174,19 @@
"description": "Either `true` to enable the wiki for this repository or `false` to disable it.",
"default": true
},
+ "has_pull_requests": {
+ "type": "boolean",
+ "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.",
+ "default": true
+ },
+ "pull_request_creation_policy": {
+ "type": "string",
+ "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.",
+ "enum": [
+ "all",
+ "collaborators_only"
+ ]
+ },
"is_template": {
"type": "boolean",
"description": "Either `true` to make this repo available as a template repository or `false` to prevent it.",
@@ -448,7 +461,12 @@
},
"permission": {
"type": "string",
- "description": "The permission to grant the team on this repository. We accept the following permissions to be set: `pull`, `triage`, `push`, `maintain`, `admin` and you can also specify a custom repository role name, if the owning organization has defined any."
+ "description": "**Closing down notice**. The permission that new repositories will be added to the team with when none is specified.",
+ "enum": [
+ "pull",
+ "push"
+ ],
+ "default": "pull"
},
"parent_team_id": {
"type": "integer",
@@ -789,6 +807,10 @@
"exempt"
],
"default": "always"
+ },
+ "name": {
+ "type": "string",
+ "description": "Human-friendly alternative to `actor_id`. The team slug, username, GitHub App slug, or repository role name (resolved using `actor_type`). Cannot be combined with `actor_id`."
}
}
}
@@ -1253,10 +1275,13 @@
"enum": [
"Team"
]
+ },
+ "slug": {
+ "type": "string",
+ "description": "Human-friendly alternative to `id`. The slug of the team that must review changes to matching files. Cannot be combined with `id`."
}
},
"required": [
- "id",
"type"
]
}
diff --git a/script/build-schema b/script/build-schema
index 7611d089e..68b59dd20 100755
--- a/script/build-schema
+++ b/script/build-schema
@@ -3,11 +3,54 @@
const $RefParser = require('@apidevtools/json-schema-ref-parser')
const fs = require('node:fs/promises');
+// Safe-settings lets users reference ruleset bypass actors and required
+// reviewers by name instead of numeric id. GitHub's published schema only
+// knows about the id fields, so after dereferencing we inject the optional
+// name-based alternatives:
+// - bypass_actors[].name -> alternative to actor_id (team slug, username,
+// GitHub App slug, or repository role name; resolved via actor_type)
+// - reviewer.slug -> alternative to reviewer.id (team slug)
+const augmentRulesetAliases = (node) => {
+ if (Array.isArray(node)) {
+ node.forEach(augmentRulesetAliases)
+ return
+ }
+ if (!node || typeof node !== 'object') return
+
+ const props = node.properties
+ if (props && typeof props === 'object') {
+ // Bypass actor object: has both actor_type and actor_id.
+ if (props.actor_type && props.actor_id && !props.name) {
+ props.name = {
+ type: 'string',
+ description: 'Human-friendly alternative to `actor_id`. The team slug, username, GitHub App slug, or repository role name (resolved using `actor_type`). Cannot be combined with `actor_id`.'
+ }
+ }
+ // Reviewer object: has id and a type enum restricted to Team.
+ if (props.id && props.type && Array.isArray(props.type.enum) &&
+ props.type.enum.length === 1 && props.type.enum[0] === 'Team' && !props.slug) {
+ props.slug = {
+ type: 'string',
+ description: 'Human-friendly alternative to `id`. The slug of the team that must review changes to matching files. Cannot be combined with `id`.'
+ }
+ if (Array.isArray(node.required)) {
+ node.required = node.required.filter(r => r !== 'id')
+ }
+ }
+ }
+
+ for (const value of Object.values(node)) {
+ if (value && typeof value === 'object') augmentRulesetAliases(value)
+ }
+};
+
(async () => {
const schema = await fs.readFile('schema/settings.json', 'utf-8').then(JSON.parse)
await $RefParser.dereference(schema)
+ augmentRulesetAliases(schema)
+
await fs.mkdir('schema/dereferenced', { recursive: true })
await fs.writeFile('schema/dereferenced/settings.json', JSON.stringify(schema, null, 2))
})().catch(console.error)
diff --git a/smoke-test.js b/smoke-test.js
index ae936f7ac..00ca7d30c 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -11,6 +11,9 @@
* 3. Run: `node smoke-test.js`
* Add --interactive to pause after each phase for manual validation.
* Set SMOKE_VERBOSE=1 for live safe-settings logs.
+ * Optional (Phase 16 — ruleset name resolution): set SMOKE_NR_USER to a
+ * username and/or SMOKE_NR_APP_SLUG to an installed GitHub App slug to also
+ * exercise User and Integration bypass-actor name resolution.
*
* Auth:
* - Octokit (GitHub App): APP_ID + PRIVATE_KEY from .env — used for most operations.
@@ -70,6 +73,10 @@ const PRIVATE_KEY = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n')
const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo']
const TEST_TEAMS = ['AD-GRP-PAYMENTS-PLATFORM-OWNERS', 'awesometeam-a-approvers', 'jefeish-edj-test']
+// Principals created on demand for the ruleset name-resolution phase (Phase 16)
+const SMOKE_NR_TEAM = 'safe-settings-smoke-nr-team'
+const SMOKE_NR_ROLE = 'safe-settings-smoke-nr-role'
+
const POLL_INTERVAL_MS = 5000
const MAX_POLL_MS = 120000
const WEBHOOK_SETTLE_MS = 15000
@@ -289,6 +296,17 @@ async function deleteTeam (org, teamSlug) {
try { await octokit.rest.teams.deleteInOrg({ org, team_slug: teamSlug }) } catch { /* ok */ }
}
+async function ensureTeam (org, name) {
+ try {
+ const { data } = await octokit.rest.teams.getByName({ org, team_slug: name })
+ return data
+ } catch { /* team doesn't exist yet */ }
+ try {
+ const { data } = await octokit.rest.teams.create({ org, name, privacy: 'closed' })
+ return data
+ } catch { return null }
+}
+
async function getCustomRepositoryRole (org, name) {
try {
const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org })
@@ -2289,6 +2307,7 @@ async function teardown () {
log('Deleting test teams...')
for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) }
+ try { await deleteTeam(ORG, SMOKE_NR_TEAM) } catch { /* ok */ }
log('Deleting custom repository role...')
try { await deleteCustomRepositoryRole(ORG, 'security-engineer') } catch { /* ok */ }
@@ -2296,6 +2315,7 @@ async function teardown () {
try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-managed') } catch { /* ok */ }
try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-external') } catch { /* ok */ }
try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-disabled') } catch { /* ok */ }
+ try { await deleteCustomRepositoryRole(ORG, SMOKE_NR_ROLE) } catch { /* ok */ }
log('Deleting org rulesets...')
try {
@@ -2306,6 +2326,7 @@ async function teardown () {
try { await deleteOrgRuleset(ORG, 'smoke-ruleset-managed') } catch { /* ok */ }
try { await deleteOrgRuleset(ORG, 'smoke-ruleset-external') } catch { /* ok */ }
try { await deleteOrgRuleset(ORG, 'smoke-ruleset-disabled') } catch { /* ok */ }
+ try { await deleteOrgRuleset(ORG, 'smoke-combined-org-ruleset') } catch { /* ok */ }
log('Resetting admin repo settings...')
const defaultBranch = await getDefaultBranch()
@@ -2321,6 +2342,29 @@ async function phase15RulesetArrayDrift () {
if (!GH_TOKEN) throw new Error('GH_TOKEN env var is required for drift tests (set to a fine-grained PAT)')
+ // ── 15-setup: Restore full test.yml (earlier phases replace it with minimal configs) ──
+ // Phases 12d and 13 overwrite repos/test.yml with configs that omit rulesets,
+ // causing safe-settings to delete "synk" from the test repo. Restore it first.
+ {
+ log('15-setup: Restoring repos/test.yml to full config (ensures "synk" ruleset exists)...')
+ const defaultBranch = await getDefaultBranch()
+ const branch = 'smoke-test-phase15-setup'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML, branch, '15-setup: restore full test repo config with rulesets')
+ const pr = await createPR(ORG, ADMIN_REPO, '15-setup: restore test.yml with rulesets', branch, defaultBranch)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const synkReady = await poll(async () => {
+ return await getRepoRuleset(ORG, 'test', 'synk')
+ }, { desc: '"synk" ruleset to be (re)created after test.yml restore', timeout: 90000 })
+ assert(synkReady !== null, '15-setup: "synk" ruleset present after restoring repos/test.yml')
+ if (!synkReady) return // cannot proceed without the ruleset
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
// ── 15a: Remove bypass_actors from "synk" ruleset ──────────────────────────
// The test repo "synk" ruleset has bypass_actors configured.
// Manually empty bypass_actors → safe-settings should detect and restore.
@@ -2455,6 +2499,147 @@ async function phase15RulesetArrayDrift () {
}
}
+// Builds a branch ruleset that references its bypass actors and required
+// reviewer by name (not numeric id), so safe-settings has to resolve them.
+// actors: [{ name, actor_type, bypass_mode }]
+function buildNameResolutionRuleset (actors) {
+ const bypassActorsYml = actors.map(a =>
+` - name: ${a.name}
+ actor_type: ${a.actor_type}
+ bypass_mode: ${a.bypass_mode}`).join('\n')
+
+ return `
+- name: smoke-name-resolution
+ target: branch
+ enforcement: active
+ bypass_actors:
+${bypassActorsYml}
+ conditions:
+ ref_name:
+ include: ["~DEFAULT_BRANCH"]
+ exclude: []
+ rules:
+ - type: pull_request
+ parameters:
+ dismiss_stale_reviews_on_push: false
+ require_code_owner_review: false
+ require_last_push_approval: false
+ required_approving_review_count: 1
+ required_review_thread_resolution: false
+ required_reviewers:
+ - minimum_approvals: 1
+ file_patterns:
+ - "*.js"
+ reviewer:
+ slug: ${SMOKE_NR_TEAM}
+ type: Team
+`
+}
+
+async function phase16RulesetNameResolution () {
+ logPhase('Phase 16: Ruleset bypass actor.name + reviewer.slug resolution')
+ const defaultBranch = await getDefaultBranch()
+ const RULESET = 'smoke-name-resolution'
+
+ // Ensure the principals exist so safe-settings can resolve names → ids.
+ log('Ensuring smoke team and custom repository role exist...')
+ const team = await ensureTeam(ORG, SMOKE_NR_TEAM)
+ if (!team) { logFail('Phase 16: could not create/find smoke team'); return }
+ const teamId = team.id
+ const role = await createCustomRepositoryRole(ORG, SMOKE_NR_ROLE, 'safe-settings smoke name-resolution role')
+ if (!role) { logFail('Phase 16: could not create custom repository role'); return }
+ const roleId = role.id
+ log(`Smoke team id=${teamId}, custom role id=${roleId}`)
+
+ // Optional principals — only exercised when the env vars are provided.
+ const extraActors = []
+ if (process.env.SMOKE_NR_USER) extraActors.push({ name: process.env.SMOKE_NR_USER, actor_type: 'User', bypass_mode: 'always' })
+ if (process.env.SMOKE_NR_APP_SLUG) extraActors.push({ name: process.env.SMOKE_NR_APP_SLUG, actor_type: 'Integration', bypass_mode: 'always' })
+
+ // ── 16a: Create a ruleset entirely by name (Team, built-in + custom role, reviewer slug) ──
+ const createActors = [
+ { name: SMOKE_NR_TEAM, actor_type: 'Team', bypass_mode: 'always' },
+ { name: 'maintain', actor_type: 'RepositoryRole', bypass_mode: 'always' },
+ { name: SMOKE_NR_ROLE, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
+ ...extraActors
+ ]
+ {
+ const branch = 'smoke-test-phase16a'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML + buildNameResolutionRuleset(createActors), branch, '16a: add name-resolution ruleset')
+ const pr = await createPR(ORG, ADMIN_REPO, '16a: ruleset bypass actor.name + reviewer.slug', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '16a: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `16a: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const details = await poll(async () => {
+ const rs = await getRepoRuleset(ORG, 'test', RULESET)
+ if (!rs) return null
+ return await getRepoRulesetDetails(ORG, 'test', rs.id)
+ }, { desc: 'name-resolution ruleset to be created', timeout: 90000 })
+
+ assert(details !== null, '16a: ruleset created from name-based config')
+ if (details) {
+ const actors = details.bypass_actors || []
+ assert(actors.some(a => a.actor_type === 'Team' && a.actor_id === teamId), `16a: Team name resolved to actor_id ${teamId}`)
+ assert(actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === 4), '16a: built-in role "maintain" resolved to actor_id 4')
+ assert(actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === roleId), `16a: custom role resolved to actor_id ${roleId}`)
+ // GitHub only ever stores ids; the human-friendly alias must not leak through.
+ assert(actors.every(a => a.name === undefined), '16a: no "name" alias present in applied ruleset (resolved to ids)')
+
+ const prRule = (details.rules || []).find(r => r.type === 'pull_request')
+ const reviewers = prRule && prRule.parameters && prRule.parameters.required_reviewers
+ const reviewer = Array.isArray(reviewers) && reviewers[0] && reviewers[0].reviewer
+ assert(reviewer && reviewer.id === teamId, `16a: reviewer.slug resolved to team id ${teamId}`)
+
+ if (process.env.SMOKE_NR_USER) assert(actors.some(a => a.actor_type === 'User' && Number.isInteger(a.actor_id)), '16a: User name resolved to actor_id')
+ if (process.env.SMOKE_NR_APP_SLUG) assert(actors.some(a => a.actor_type === 'Integration' && Number.isInteger(a.actor_id)), '16a: Integration slug resolved to actor_id')
+ }
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
+ // ── 16b: Modify the ruleset by name — swap built-in maintain(4) → admin(5) ──
+ const modifyActors = createActors.map(a =>
+ (a.actor_type === 'RepositoryRole' && a.name === 'maintain') ? { ...a, name: 'admin' } : a)
+ {
+ const branch = 'smoke-test-phase16b'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML + buildNameResolutionRuleset(modifyActors), branch, '16b: modify name-resolution ruleset')
+ const pr = await createPR(ORG, ADMIN_REPO, '16b: modify ruleset bypass actor by name', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '16b: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `16b: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const updated = await poll(async () => {
+ const rs = await getRepoRuleset(ORG, 'test', RULESET)
+ if (!rs) return null
+ const d = await getRepoRulesetDetails(ORG, 'test', rs.id)
+ const actors = (d && d.bypass_actors) || []
+ const hasAdmin = actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === 5)
+ const hasMaintain = actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === 4)
+ return (hasAdmin && !hasMaintain) ? d : null
+ }, { desc: 'ruleset to be updated with admin role (5) replacing maintain (4)', timeout: 90000 })
+
+ assert(updated !== null, '16b: ruleset modified by name — maintain(4) replaced with admin(5)')
+ if (updated) {
+ const actors = updated.bypass_actors || []
+ assert(actors.some(a => a.actor_type === 'Team' && a.actor_id === teamId), '16b: Team bypass actor preserved across modification')
+ assert(actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === roleId), '16b: custom role bypass actor preserved across modification')
+ }
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+}
+
// ─── Main ────────────────────────────────────────────────────────────────────
async function main () {
@@ -2505,7 +2690,8 @@ async function main () {
['Phase 12: rulesets', phase12Rulesets],
['Phase 13: variables', phase13Variables],
['Phase 14: regressions', phase14RegressionCoverage],
- ['Phase 15: Ruleset array drift', phase15RulesetArrayDrift]
+ ['Phase 15: Ruleset array drift', phase15RulesetArrayDrift],
+ ['Phase 16: Ruleset name/slug resolution', phase16RulesetNameResolution]
]
// When --phase is given, only run setup (phase 0) + the requested phase(s).
diff --git a/test/unit/lib/plugins/rulesets.test.js b/test/unit/lib/plugins/rulesets.test.js
index 15319cff0..df72e0ceb 100644
--- a/test/unit/lib/plugins/rulesets.test.js
+++ b/test/unit/lib/plugins/rulesets.test.js
@@ -818,4 +818,200 @@ describe('Rulesets', () => {
expect(result).toBe(true)
})
})
+
+ describe('name to id resolution', () => {
+ function bypassRuleset (actorEntry) {
+ return {
+ name: 'Main protection',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: { ref_name: { include: ['refs/heads/main'], exclude: [] } },
+ bypass_actors: [Object.assign({ bypass_mode: 'always' }, actorEntry)],
+ rules: [{ type: 'creation' }]
+ }
+ }
+
+ function reviewerRuleset (reviewer) {
+ return {
+ name: 'Code review',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: { ref_name: { include: ['refs/heads/main'], exclude: [] } },
+ rules: [
+ {
+ type: 'pull_request',
+ parameters: {
+ required_approving_review_count: 1,
+ dismiss_stale_reviews_on_push: false,
+ require_code_owner_review: false,
+ require_last_push_approval: false,
+ required_review_thread_resolution: false,
+ required_reviewers: [
+ { minimum_approvals: 1, file_patterns: ['*.js'], reviewer }
+ ]
+ }
+ }
+ ]
+ }
+ }
+
+ it('resolves a Team bypass actor name to actor_id and strips the alias', async () => {
+ github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 42 } }) }
+ const plugin = configure([bypassRuleset({ name: 'my-team', actor_type: 'Team' })], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.teams.getByName).toHaveBeenCalledWith({ org: 'jitran', team_slug: 'my-team' })
+ expect(plugin.rulesets[0].bypass_actors[0]).toEqual({ actor_id: 42, actor_type: 'Team', bypass_mode: 'always' })
+ expect(plugin.rulesets[0].bypass_actors[0].name).toBeUndefined()
+ })
+
+ it('resolves a User bypass actor login to actor_id', async () => {
+ github.request = jest.fn().mockResolvedValue({ data: { id: 7 } })
+ const plugin = configure([bypassRuleset({ name: 'octocat', actor_type: 'User' })], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.request).toHaveBeenCalledWith('GET /users/{username}', { username: 'octocat' })
+ expect(plugin.rulesets[0].bypass_actors[0]).toEqual({ actor_id: 7, actor_type: 'User', bypass_mode: 'always' })
+ })
+
+ it('resolves an Integration (GitHub App) slug to actor_id', async () => {
+ github.request = jest.fn().mockResolvedValue({ data: { id: 99 } })
+ const plugin = configure([bypassRuleset({ name: 'my-app', actor_type: 'Integration' })], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.request).toHaveBeenCalledWith('GET /apps/{app_slug}', { app_slug: 'my-app' })
+ expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(99)
+ })
+
+ it('resolves built-in RepositoryRole names from the static map without an API call', async () => {
+ github.request = jest.fn()
+ const plugin = configure([
+ bypassRuleset({ name: 'admin', actor_type: 'RepositoryRole' })
+ ], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.request).not.toHaveBeenCalled()
+ expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(5)
+ })
+
+ it('pins the built-in RepositoryRole ids', async () => {
+ const expected = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 }
+ for (const [name, id] of Object.entries(expected)) {
+ const plugin = configure([bypassRuleset({ name, actor_type: 'RepositoryRole' })], 'org')
+ await plugin.resolveNamesToIds()
+ expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(id)
+ }
+ })
+
+ it('resolves a custom RepositoryRole name via the custom-repository-roles API', async () => {
+ github.request = jest.fn().mockResolvedValue({ data: { custom_roles: [{ id: 123, name: 'Security' }] } })
+ const plugin = configure([bypassRuleset({ name: 'Security', actor_type: 'RepositoryRole' })], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.request).toHaveBeenCalledWith('GET /orgs/{org}/custom-repository-roles', { org: 'jitran' })
+ expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(123)
+ })
+
+ it('resolves a reviewer slug to id and strips the alias', async () => {
+ github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 555 } }) }
+ const plugin = configure([reviewerRuleset({ slug: 'reviewers', type: 'Team' })], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ const reviewer = plugin.rulesets[0].rules[0].parameters.required_reviewers[0].reviewer
+ expect(github.teams.getByName).toHaveBeenCalledWith({ org: 'jitran', team_slug: 'reviewers' })
+ expect(reviewer).toEqual({ id: 555, type: 'Team' })
+ expect(reviewer.slug).toBeUndefined()
+ })
+
+ it('caches repeated lookups so each name resolves with a single API call', async () => {
+ github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 42 } }) }
+ const plugin = configure([
+ {
+ name: 'Multi',
+ target: 'branch',
+ enforcement: 'active',
+ conditions: { ref_name: { include: ['refs/heads/main'], exclude: [] } },
+ bypass_actors: [
+ { name: 'my-team', actor_type: 'Team', bypass_mode: 'always' },
+ { name: 'my-team', actor_type: 'Team', bypass_mode: 'pull_request' }
+ ],
+ rules: [{ type: 'creation' }]
+ }
+ ], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.teams.getByName).toHaveBeenCalledTimes(1)
+ expect(plugin.rulesets[0].bypass_actors.map(a => a.actor_id)).toEqual([42, 42])
+ })
+
+ it('leaves numeric actor_id untouched and makes no lookup (backward compatible)', async () => {
+ github.teams = { getByName: jest.fn() }
+ github.request = jest.fn()
+ const plugin = configure([bypassRuleset({ actor_id: 234, actor_type: 'Team' })], 'org')
+
+ await plugin.resolveNamesToIds()
+
+ expect(github.teams.getByName).not.toHaveBeenCalled()
+ expect(github.request).not.toHaveBeenCalled()
+ expect(plugin.rulesets[0].bypass_actors[0]).toEqual({ actor_id: 234, actor_type: 'Team', bypass_mode: 'always' })
+ })
+
+ it('throws when both name and actor_id are provided', async () => {
+ const plugin = configure([bypassRuleset({ name: 'my-team', actor_id: 1, actor_type: 'Team' })], 'org')
+ await expect(plugin.resolveNamesToIds()).rejects.toThrow(/both 'name'.*and 'actor_id'/)
+ })
+
+ it('throws when both reviewer slug and id are provided', async () => {
+ const plugin = configure([reviewerRuleset({ slug: 'reviewers', id: 1, type: 'Team' })], 'org')
+ await expect(plugin.resolveNamesToIds()).rejects.toThrow(/both 'slug'.*and 'id'/)
+ })
+
+ it('throws when an actor_type does not support name resolution', async () => {
+ const plugin = configure([bypassRuleset({ name: 'whoever', actor_type: 'DeployKey' })], 'org')
+ await expect(plugin.resolveNamesToIds()).rejects.toThrow(/only supported for Team, User, Integration, and RepositoryRole/)
+ })
+
+ it('throws a clear error when a team slug cannot be resolved', async () => {
+ const notFound = new Error('Not Found')
+ notFound.status = 404
+ github.teams = { getByName: jest.fn().mockRejectedValue(notFound) }
+ const plugin = configure([bypassRuleset({ name: 'ghost-team', actor_type: 'Team' })], 'org')
+ await expect(plugin.resolveNamesToIds()).rejects.toThrow(/Unable to resolve Team slug 'ghost-team'/)
+ })
+
+ it('sync sends the resolved actor_id to the API', async () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+ github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 42 } }) }
+ const postCalls = []
+ github.request = jest.fn().mockImplementation((route, body) => {
+ if (route.startsWith('POST')) postCalls.push({ route, body })
+ return Promise.resolve('request')
+ })
+ github.request.endpoint = jest.fn().mockImplementation((route, body) => ({ url: route, body }))
+ github.request.endpoint.merge = jest.fn().mockReturnValue({ method: 'GET', url: '/orgs/jitran/rulesets', headers: version })
+
+ const plugin = configure([bypassRuleset({ name: 'my-team', actor_type: 'Team' })], 'org')
+ await plugin.sync()
+
+ expect(postCalls).toHaveLength(1)
+ expect(postCalls[0].route).toBe('POST /orgs/{org}/rulesets')
+ expect(postCalls[0].body.bypass_actors).toEqual([{ actor_id: 42, actor_type: 'Team', bypass_mode: 'always' }])
+ })
+
+ it('sync surfaces a resolution failure as an error in nop mode', async () => {
+ github.paginate = jest.fn().mockResolvedValue([])
+ const plugin = configure([bypassRuleset({ name: 'my-team', actor_id: 1, actor_type: 'Team' })], 'org', true)
+
+ const result = await plugin.sync()
+ const flat = result.flat()
+ expect(flat.some(command => command.type === 'ERROR')).toBe(true)
+ })
+ })
})
From 2876b3f2de5ee0fd8b9d7c80a4146bb0a722a543 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 11:06:37 -0400
Subject: [PATCH 25/67] Fix suborg-applied settings not removed when targeting
rules change
When a suborg.yml file changes its targeting rules (suborgrepos,
suborgteams, or suborgproperties), repos that no longer match the
updated targeting were not having their suborg-applied settings
(e.g. rulesets) removed. This happened because getSubOrgConfigs()
only resolves the new targeting, and repos not in the new targeting
were skipped in updateRepos().
Fix: Load the previous version of changed suborg config files from
the base ref (payload.before for push events, pull_request.base.ref
for PR/NOP mode), resolve which repos were previously targeted,
compare with current targeting, and process removed repos so
diffable's sync() detects and removes orphaned rulesets.
Changes:
- index.js: Pass payload.after/payload.before as ref/baseRef to
syncSelectedSettings in push handler
- lib/settings.js: Add getReposRemovedFromSubOrgTargeting() method
that compares old vs new targeting to find removed repos
- lib/settings.js: Add loadYamlFromRef() helper to load config
from a specific git ref without cache interference
- lib/settings.js: Update syncSelectedRepos to accept baseRef,
identify removed repos, and process them before the suborg loop
- test/unit/lib/settings.test.js: Add tests for targeting removal
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
index.js | 4 +-
lib/settings.js | 123 ++++++++++++++++++++++++-
test/unit/lib/settings.test.js | 160 +++++++++++++++++++++++++++++++++
3 files changed, 284 insertions(+), 3 deletions(-)
diff --git a/index.js b/index.js
index 09ba59df9..d3b801c54 100644
--- a/index.js
+++ b/index.js
@@ -100,7 +100,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
- return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref, baseConfig)
+ return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref, baseConfig, baseRef)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
@@ -303,7 +303,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
if (repoChanges.length > 0 || subOrgChanges.length > 0) {
- return syncSelectedSettings(false, context, repoChanges, subOrgChanges)
+ return syncSelectedSettings(false, context, repoChanges, subOrgChanges, payload.after, payload.before)
}
robot.log.debug(`No changes in '${Settings.FILE_PATH}' detected, returning...`)
diff --git a/lib/settings.js b/lib/settings.js
index 9242044e7..a9a2deb67 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -584,7 +584,7 @@ class Settings {
}
}
- static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref, baseConfig) {
+ static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref, baseConfig, baseRef) {
const settings = new Settings(nop, context, context.repo(), config, ref, null, baseConfig)
settings.setChangedConfigTargets(repos, subOrgs)
@@ -594,6 +594,31 @@ class Settings {
settings.subOrgConfigs = await settings.getSubOrgConfigs()
settings.trackChangedReposFromSubOrgConfigs()
+ // Identify repos removed from suborg targeting due to targeting rule
+ // changes in the suborg config file. These repos need processing so
+ // their suborg-applied settings (e.g. rulesets) are cleaned up.
+ if (subOrgs.length > 0 && baseRef) {
+ const removedRepos = await settings.getReposRemovedFromSubOrgTargeting(subOrgs, baseRef)
+ if (removedRepos.length > 0) {
+ settings.log.debug(`Repos removed from suborg targeting: ${JSON.stringify(removedRepos)}`)
+ // Add removed repos to changedRepoNames so NOP filtering keeps their results
+ if (!settings.changedRepoNames) {
+ settings.changedRepoNames = new Set()
+ }
+ for (const repoName of removedRepos) {
+ settings.changedRepoNames.add(repoName)
+ }
+ // Process removed repos with org-only config (no suborg layer)
+ for (const repoName of removedRepos) {
+ if (settings.isRestricted(repoName)) continue
+ if (settings.processedRepoNames.has(repoName)) continue
+ const repo = { owner: context.repo().owner, repo: repoName }
+ settings.repoConfigs = await settings.getRepoConfigs(repo)
+ await settings.updateRepos(repo)
+ }
+ }
+ }
+
// Re-eval is enabled only for the per-repo iteration (repo-yml change
// path). The trailing suborg iteration below already iterates all suborg
// repos, so it is left with the flag off.
@@ -732,6 +757,102 @@ class Settings {
})
}
+ // Identify repos that were previously targeted by suborg config files but
+ // are no longer targeted after the targeting rules changed. Loads the
+ // previous version of each changed suborg file from `baseRef`, resolves its
+ // targeting, and returns repo names present in the old targeting but absent
+ // from the current `this.subOrgConfigs`.
+ async getReposRemovedFromSubOrgTargeting (changedSubOrgs, baseRef) {
+ if (!changedSubOrgs || changedSubOrgs.length === 0 || !baseRef) {
+ return []
+ }
+
+ const removedRepos = []
+
+ for (const suborg of changedSubOrgs) {
+ const filePath = suborg.path
+ if (!filePath) continue
+
+ // Load the previous version of this suborg config file
+ let previousData
+ try {
+ previousData = await this.loadYamlFromRef(filePath, baseRef)
+ } catch (e) {
+ this.log.debug(`Could not load previous suborg config from ref ${baseRef}: ${e.message}`)
+ continue
+ }
+
+ if (!previousData) continue
+
+ // Resolve repos targeted by the old config
+ const previouslyTargetedRepos = new Set()
+
+ // 1. suborgrepos: glob patterns (these are repo name patterns)
+ if (previousData.suborgrepos && Array.isArray(previousData.suborgrepos)) {
+ for (const repoPattern of previousData.suborgrepos) {
+ previouslyTargetedRepos.add(repoPattern)
+ }
+ }
+
+ // 2. suborgteams: resolve via GitHub API (team membership is live state)
+ if (previousData.suborgteams && Array.isArray(previousData.suborgteams)) {
+ try {
+ const teamPromises = previousData.suborgteams.map(teamslug =>
+ this.getReposForTeam(teamslug)
+ )
+ const teamResults = await Promise.all(teamPromises)
+ for (const repos of teamResults) {
+ for (const repo of repos) {
+ previouslyTargetedRepos.add(repo.name)
+ }
+ }
+ } catch (e) {
+ this.log.debug(`Error resolving previous suborgteams: ${e.message}`)
+ }
+ }
+
+ // 3. suborgproperties: resolve via GitHub API (property values are live state)
+ if (previousData.suborgproperties && Array.isArray(previousData.suborgproperties)) {
+ try {
+ const subOrgRepositories = await this.getSubOrgRepositories(previousData.suborgproperties)
+ for (const repo of subOrgRepositories) {
+ previouslyTargetedRepos.add(repo.repository_name)
+ }
+ } catch (e) {
+ this.log.debug(`Error resolving previous suborgproperties: ${e.message}`)
+ }
+ }
+
+ // Find repos in previous targeting that are NOT in current targeting
+ for (const repoName of previouslyTargetedRepos) {
+ if (!this.getSubOrgConfig(repoName)) {
+ removedRepos.push(repoName)
+ }
+ }
+ }
+
+ return [...new Set(removedRepos)]
+ }
+
+ // Load a YAML file from a specific git ref, bypassing the file cache.
+ // Used to load previous versions of config files for comparison.
+ async loadYamlFromRef (filePath, ref) {
+ const repo = { owner: this.repo.owner, repo: env.ADMIN_REPO }
+ const params = Object.assign(repo, { path: filePath, ref })
+
+ const response = await this.github.repos.getContent(params)
+
+ if (Array.isArray(response.data)) {
+ return null
+ }
+
+ if (typeof response.data.content !== 'string') {
+ return null
+ }
+
+ return yaml.load(Buffer.from(response.data.content, 'base64').toString()) || {}
+ }
+
// Create a check in the Admin repo for safe-settings.
async createCheckRun () {
const startTime = new Date()
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 146601894..06c0a4d67 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1406,4 +1406,164 @@ repository:
})
})
})
+
+ describe('getReposRemovedFromSubOrgTargeting', () => {
+ let settings
+
+ beforeEach(() => {
+ stubConfig = { restrictedRepos: {} }
+ settings = createSettings(stubConfig)
+ })
+
+ it('returns empty array when no changedSubOrgs provided', async () => {
+ const result = await settings.getReposRemovedFromSubOrgTargeting([], 'prev-sha')
+ expect(result).toEqual([])
+ })
+
+ it('returns empty array when no baseRef provided', async () => {
+ const result = await settings.getReposRemovedFromSubOrgTargeting([{ path: '.github/suborgs/frontend.yml' }], null)
+ expect(result).toEqual([])
+ })
+
+ it('identifies repos removed from suborgrepos targeting', async () => {
+ // Previous config had repo-a and repo-b in suborgrepos
+ const previousContent = Buffer.from(yaml.dump({
+ suborgrepos: ['repo-a', 'repo-b'],
+ teams: [{ name: 'core', permission: 'push' }]
+ })).toString('base64')
+
+ stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => {
+ if (params.ref === 'prev-sha') {
+ return Promise.resolve({ data: { content: previousContent } })
+ }
+ // Current config: default mock (has new-repo in suborgrepos)
+ const currentContent = Buffer.from(yaml.dump({
+ suborgrepos: ['repo-b'],
+ teams: [{ name: 'core', permission: 'push' }]
+ })).toString('base64')
+ return Promise.resolve({ data: { content: currentContent } })
+ })
+
+ // Current subOrgConfigs only has repo-b (repo-a was removed from targeting)
+ settings.subOrgConfigs = {
+ 'repo-b': { source: '.github/suborgs/frontend.yml' }
+ }
+
+ const result = await settings.getReposRemovedFromSubOrgTargeting(
+ [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }],
+ 'prev-sha'
+ )
+
+ expect(result).toContain('repo-a')
+ expect(result).not.toContain('repo-b')
+ })
+
+ it('identifies repos removed from suborgteams targeting', async () => {
+ // Previous config used suborgteams: [team-a]
+ const previousContent = Buffer.from(yaml.dump({
+ suborgteams: ['team-a'],
+ teams: [{ name: 'core', permission: 'push' }]
+ })).toString('base64')
+
+ stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => {
+ if (params.ref === 'prev-sha') {
+ return Promise.resolve({ data: { content: previousContent } })
+ }
+ return Promise.resolve({ data: { content: previousContent } })
+ })
+
+ // Mock getReposForTeam to return repos for team-a
+ settings.getReposForTeam = jest.fn().mockResolvedValue([
+ { name: 'team-repo-1' },
+ { name: 'team-repo-2' }
+ ])
+
+ // Current subOrgConfigs: only team-repo-1 still matches (team-repo-2 was removed)
+ settings.subOrgConfigs = {
+ 'team-repo-1': { source: '.github/suborgs/frontend.yml' }
+ }
+
+ const result = await settings.getReposRemovedFromSubOrgTargeting(
+ [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }],
+ 'prev-sha'
+ )
+
+ expect(result).toContain('team-repo-2')
+ expect(result).not.toContain('team-repo-1')
+ })
+
+ it('identifies repos removed from suborgproperties targeting', async () => {
+ // Previous config used suborgproperties
+ const previousContent = Buffer.from(yaml.dump({
+ suborgproperties: [{ EDP: true }],
+ teams: [{ name: 'core', permission: 'push' }]
+ })).toString('base64')
+
+ stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => {
+ if (params.ref === 'prev-sha') {
+ return Promise.resolve({ data: { content: previousContent } })
+ }
+ return Promise.resolve({ data: { content: previousContent } })
+ })
+
+ // Mock getSubOrgRepositories to return repos with the property
+ settings.getSubOrgRepositories = jest.fn().mockResolvedValue([
+ { repository_name: 'prop-repo-1' },
+ { repository_name: 'prop-repo-2' }
+ ])
+
+ // Current subOrgConfigs: only prop-repo-1 still matches
+ settings.subOrgConfigs = {
+ 'prop-repo-1': { source: '.github/suborgs/frontend.yml' }
+ }
+
+ const result = await settings.getReposRemovedFromSubOrgTargeting(
+ [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }],
+ 'prev-sha'
+ )
+
+ expect(result).toContain('prop-repo-2')
+ expect(result).not.toContain('prop-repo-1')
+ })
+
+ it('deduplicates removed repos across multiple suborg files', async () => {
+ const previousContent = Buffer.from(yaml.dump({
+ suborgrepos: ['repo-a', 'repo-b']
+ })).toString('base64')
+
+ stubContext.octokit.repos.getContent = jest.fn().mockResolvedValue({
+ data: { content: previousContent }
+ })
+
+ // Neither repo matches current targeting
+ settings.subOrgConfigs = {}
+
+ const result = await settings.getReposRemovedFromSubOrgTargeting(
+ [
+ { path: '.github/suborgs/frontend.yml', name: 'frontend' },
+ { path: '.github/suborgs/frontend.yml', name: 'frontend' } // duplicate
+ ],
+ 'prev-sha'
+ )
+
+ // Should be deduplicated
+ const repoACount = result.filter(r => r === 'repo-a').length
+ expect(repoACount).toBe(1)
+ })
+
+ it('handles 404 gracefully when previous file does not exist', async () => {
+ stubContext.octokit.repos.getContent = jest.fn().mockRejectedValue(
+ Object.assign(new Error('Not Found'), { status: 404 })
+ )
+
+ settings.subOrgConfigs = {}
+
+ const result = await settings.getReposRemovedFromSubOrgTargeting(
+ [{ path: '.github/suborgs/new-suborg.yml', name: 'new-suborg' }],
+ 'prev-sha'
+ )
+
+ expect(result).toEqual([])
+ })
+ })
}) // Settings Tests
From 8659138a5c00c46bd04fbf39d53acc09a7d63667 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 11:30:47 -0400
Subject: [PATCH 26/67] Add phase 5 smoke test for suborg targeting rule change
removal
Adds a sub-test to phase 5 that narrows suborg targeting from
suborgteams to suborgrepos (excluding demo-repo-service1), then
verifies the suborg ruleset is removed from the dropped repo while
retained on the still-targeted repo. Restores team-targeted config
afterward for subsequent phases.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
smoke-test.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 61 insertions(+)
diff --git a/smoke-test.js b/smoke-test.js
index 00ca7d30c..27013b4ec 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -644,6 +644,15 @@ const SUBORG_EXPERT_SERVICES_PROPERTY_YML = SUBORG_EXPERT_SERVICES_YML.replace(
'suborgproperties:\n - ent-ownership: expert-services'
)
+// Suborg config that narrows targeting to only demo-repo-service2 via
+// suborgrepos. Used to test that repos dropping out of suborg targeting
+// (due to targeting rule changes in the suborg.yml) have their
+// suborg-applied rulesets removed.
+const SUBORG_EXPERT_SERVICES_NARROW_YML = SUBORG_EXPERT_SERVICES_YML.replace(
+ 'suborgteams:\n - expert-services-developers',
+ 'suborgrepos:\n - demo-repo-service2'
+)
+
const REPO_DEMO_SERVICE1_ARCHIVED_YML = `# Safe-Settings Configuration
repository:
name: demo-repo-service1
@@ -1404,9 +1413,61 @@ async function phase5Suborg () {
if (!await safeMerge(ORG, ADMIN_REPO, pr3.number)) return
await sleep(WEBHOOK_SETTLE_MS)
+ // ── Sub-test: suborg targeting rule change removes rulesets from dropped repos ──
+ log('Verifying suborg ruleset is on demo-repo-service1 before targeting change...')
+ const demo1RulesetBeforeNarrow = await poll(async () => {
+ return await getRepoRuleset(ORG, 'demo-repo-service1', suborgRulesetName)
+ }, { desc: 'suborg ruleset on demo-repo-service1 before narrowing', timeout: 90000 })
+ assert(demo1RulesetBeforeNarrow !== null, 'Suborg ruleset present on demo-repo-service1 before targeting change')
+
+ const branch4 = 'smoke-test-phase5-narrow-targeting'
+ await deleteBranch(ORG, ADMIN_REPO, branch4)
+ await createBranch(ORG, ADMIN_REPO, branch4)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_NARROW_YML, branch4, 'Narrow suborg targeting to only demo-repo-service2')
+
+ const pr4 = await createPR(ORG, ADMIN_REPO, 'Smoke test: narrow suborg targeting (remove demo-repo-service1)', branch4, defaultBranch)
+ log('Waiting for NOP check run on narrowed targeting...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun4 = await waitForCheckRun(ORG, ADMIN_REPO, pr4.head.sha)
+ assert(checkRun4 !== null, 'Check run completed for narrowed targeting')
+ if (checkRun4) assert(checkRun4.conclusion === 'success', `Check run conclusion is success (got: ${checkRun4.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr4.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ log('Checking suborg ruleset removed from demo-repo-service1 after targeting change...')
+ const demo1RulesetAfterNarrow = await poll(async () => {
+ const ruleset = await getRepoRuleset(ORG, 'demo-repo-service1', suborgRulesetName)
+ return ruleset === null ? true : null
+ }, { desc: 'suborg ruleset to be removed from demo-repo-service1 after targeting narrowed', timeout: 90000 })
+ assert(demo1RulesetAfterNarrow === true, 'Suborg ruleset removed from demo-repo-service1 after targeting rule change')
+
+ const demo2RulesetAfterNarrow = await poll(async () => {
+ return await getRepoRuleset(ORG, 'demo-repo-service2', suborgRulesetName)
+ }, { desc: 'suborg ruleset retained on demo-repo-service2 after narrowing', timeout: 60000 })
+ assert(demo2RulesetAfterNarrow !== null, 'Suborg ruleset retained on demo-repo-service2 after targeting rule change')
+
+ // Restore team-targeted config for subsequent phases
+ const branch5 = 'smoke-test-phase5-restore-after-narrow'
+ await deleteBranch(ORG, ADMIN_REPO, branch5)
+ await createBranch(ORG, ADMIN_REPO, branch5)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_YML, branch5, 'Restore team-targeted expert-services suborg config after narrow test')
+
+ const pr5 = await createPR(ORG, ADMIN_REPO, 'Smoke test: restore suborg after narrow targeting test', branch5, defaultBranch)
+ log('Waiting for NOP check run on restore...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun5 = await waitForCheckRun(ORG, ADMIN_REPO, pr5.head.sha)
+ assert(checkRun5 !== null, 'Check run completed for restore after narrow')
+ if (checkRun5) assert(checkRun5.conclusion === 'success', `Check run conclusion is success (got: ${checkRun5.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr5.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
await deleteBranch(ORG, ADMIN_REPO, branch)
await deleteBranch(ORG, ADMIN_REPO, branch2)
await deleteBranch(ORG, ADMIN_REPO, branch3)
+ await deleteBranch(ORG, ADMIN_REPO, branch4)
+ await deleteBranch(ORG, ADMIN_REPO, branch5)
}
async function phase6Archive () {
From 90f0dea8649042ef740683ed938c98688f9ad020 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:08:41 -0400
Subject: [PATCH 27/67] Fix custom_properties test to use github.rest.repos
mock
The plugin was updated to use github.rest.repos.* but the test was
still mocking github.repos.*, causing TypeError failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../lib/plugins/custom_properties.test.js | 22 ++++++++++---------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/test/unit/lib/plugins/custom_properties.test.js b/test/unit/lib/plugins/custom_properties.test.js
index 429544f8a..6afc8228b 100644
--- a/test/unit/lib/plugins/custom_properties.test.js
+++ b/test/unit/lib/plugins/custom_properties.test.js
@@ -15,9 +15,11 @@ describe('CustomProperties', () => {
beforeEach(() => {
github = {
paginate: jest.fn(),
- repos: {
- getCustomPropertiesValues: jest.fn(),
- createOrUpdateCustomPropertiesValues: jest.fn()
+ rest: {
+ repos: {
+ getCustomPropertiesValues: jest.fn(),
+ createOrUpdateCustomPropertiesValues: jest.fn()
+ }
}
}
@@ -42,7 +44,7 @@ describe('CustomProperties', () => {
const result = await plugin.find()
expect(github.paginate).toHaveBeenCalledWith(
- github.repos.getCustomPropertiesValues,
+ github.rest.repos.getCustomPropertiesValues,
{
owner,
repo,
@@ -75,14 +77,14 @@ describe('CustomProperties', () => {
return plugin.sync().then(() => {
expect(github.paginate).toHaveBeenCalledWith(
- github.repos.getCustomPropertiesValues,
+ github.rest.repos.getCustomPropertiesValues,
{
owner,
repo,
per_page: 100
}
)
- expect(github.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalledWith({
+ expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalledWith({
owner,
repo,
properties: [
@@ -92,7 +94,7 @@ describe('CustomProperties', () => {
}
]
})
- expect(github.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
+ expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
owner,
repo,
properties: [
@@ -102,7 +104,7 @@ describe('CustomProperties', () => {
}
]
})
- expect(github.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
+ expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
owner,
repo,
properties: [
@@ -112,7 +114,7 @@ describe('CustomProperties', () => {
}
]
})
- expect(github.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
+ expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
owner,
repo,
properties: [
@@ -127,7 +129,7 @@ describe('CustomProperties', () => {
// const plugin = configure([{ name: 'Test', value: 'test' }])
// await plugin.update({ name: 'test', value: 'old' }, { name: 'test', value: 'test' })
- // expect(github.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
+ // expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith({
// owner,
// repo,
// properties: [
From 30c514a52f0320c147eba07ca67784a1aa314c8c Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:50:55 -0400
Subject: [PATCH 28/67] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
lib/settings.js | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index a9a2deb67..655154eba 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -787,10 +787,16 @@ class Settings {
// Resolve repos targeted by the old config
const previouslyTargetedRepos = new Set()
- // 1. suborgrepos: glob patterns (these are repo name patterns)
+ // 1. suborgrepos: resolve glob patterns to concrete repo names
if (previousData.suborgrepos && Array.isArray(previousData.suborgrepos)) {
+ const allRepos = await this.github.paginate('GET /installation/repositories')
for (const repoPattern of previousData.suborgrepos) {
- previouslyTargetedRepos.add(repoPattern)
+ const glob = new Glob(repoPattern)
+ for (const repo of allRepos) {
+ if (glob.test(repo.name)) {
+ previouslyTargetedRepos.add(repo.name)
+ }
+ }
}
}
From 6d9e38c64d22c222a8cd47c7fad7384fa5eb1aa3 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:51:22 -0400
Subject: [PATCH 29/67] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
test/unit/lib/settings.test.js | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 06c0a4d67..1ab9c92ca 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1458,6 +1458,40 @@ repository:
expect(result).not.toContain('repo-b')
})
+ it('identifies repos removed from suborgrepos glob targeting', async () => {
+ const previousContent = Buffer.from(yaml.dump({
+ suborgrepos: ['team-*'],
+ teams: [{ name: 'core', permission: 'push' }]
+ })).toString('base64')
+
+ stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => {
+ if (params.ref === 'prev-sha') {
+ return Promise.resolve({ data: { content: previousContent } })
+ }
+ return Promise.resolve({ data: { content: previousContent } })
+ })
+
+ stubContext.octokit.paginate = jest.fn().mockResolvedValue([
+ { owner: { login: 'test' }, name: 'team-a1' },
+ { owner: { login: 'test' }, name: 'team-b1' },
+ { owner: { login: 'test' }, name: 'other' }
+ ])
+
+ // Current targeting only matches team-a*
+ settings.subOrgConfigs = {
+ 'team-a*': { source: '.github/suborgs/frontend.yml' }
+ }
+
+ const result = await settings.getReposRemovedFromSubOrgTargeting(
+ [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }],
+ 'prev-sha'
+ )
+
+ expect(result).toContain('team-b1')
+ expect(result).not.toContain('team-a1')
+ expect(result).not.toContain('other')
+ })
+
it('identifies repos removed from suborgteams targeting', async () => {
// Previous config used suborgteams: [team-a]
const previousContent = Buffer.from(yaml.dump({
From 9b49716a210b5bd9822e758ab030fce47c1eea7c Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:58:21 -0400
Subject: [PATCH 30/67] Fix suborgrepos tests to mock paginate for glob
resolution
The implementation now resolves suborgrepos glob patterns against
installation repos via github.paginate(). Updated the tests to mock
paginate returning the repos that match the patterns.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
test/unit/lib/settings.test.js | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 1ab9c92ca..74de9d7ed 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1444,6 +1444,12 @@ repository:
return Promise.resolve({ data: { content: currentContent } })
})
+ // Mock installation repos for glob resolution
+ stubContext.octokit.paginate = jest.fn().mockResolvedValue([
+ { name: 'repo-a', owner: { login: 'test' } },
+ { name: 'repo-b', owner: { login: 'test' } }
+ ])
+
// Current subOrgConfigs only has repo-b (repo-a was removed from targeting)
settings.subOrgConfigs = {
'repo-b': { source: '.github/suborgs/frontend.yml' }
@@ -1569,6 +1575,12 @@ repository:
data: { content: previousContent }
})
+ // Mock installation repos for glob resolution
+ stubContext.octokit.paginate = jest.fn().mockResolvedValue([
+ { name: 'repo-a', owner: { login: 'test' } },
+ { name: 'repo-b', owner: { login: 'test' } }
+ ])
+
// Neither repo matches current targeting
settings.subOrgConfigs = {}
From ad373e4ca07c2e4f58514944d302cdc4d15cde03 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 23 Jun 2026 14:07:07 -0400
Subject: [PATCH 31/67] Fix suborg targeting removal: inject empty plugin
sections for cleanup
When repos are removed from suborg targeting, the rulesets plugin was
never instantiated because returnRepoSpecificConfigs strips 'rulesets'
from org config, and the repo has no suborg/override rulesets section.
Without the plugin, existing suborg-applied rulesets were never removed.
Fix: Track which plugin sections the previous suborg config provided
(e.g. rulesets, teams) and inject them as empty arrays into the merged
config for removed repos. This ensures the plugins are instantiated
and sync() detects/removes existing entries.
Also fix smoke test to use 'test' repo (exists in Phase 1) instead of
'demo-repo-service2' (not created until Phase 7) for the retention
assertion.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
lib/settings.js | 45 +++++++++++++++++++++++++++++++---
smoke-test.js | 14 +++++------
test/unit/lib/settings.test.js | 33 +++++++++++++------------
3 files changed, 66 insertions(+), 26 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 655154eba..3a10e4fc3 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -598,9 +598,12 @@ class Settings {
// changes in the suborg config file. These repos need processing so
// their suborg-applied settings (e.g. rulesets) are cleaned up.
if (subOrgs.length > 0 && baseRef) {
- const removedRepos = await settings.getReposRemovedFromSubOrgTargeting(subOrgs, baseRef)
+ const removalResult = await settings.getReposRemovedFromSubOrgTargeting(subOrgs, baseRef)
+ const removedRepos = removalResult.repos
+ const previousPluginSections = removalResult.previousPluginSections
if (removedRepos.length > 0) {
settings.log.debug(`Repos removed from suborg targeting: ${JSON.stringify(removedRepos)}`)
+ settings.log.debug(`Previous suborg plugin sections to clean up: ${JSON.stringify(previousPluginSections)}`)
// Add removed repos to changedRepoNames so NOP filtering keeps their results
if (!settings.changedRepoNames) {
settings.changedRepoNames = new Set()
@@ -608,7 +611,11 @@ class Settings {
for (const repoName of removedRepos) {
settings.changedRepoNames.add(repoName)
}
- // Process removed repos with org-only config (no suborg layer)
+ // Process removed repos with org-only config (no suborg layer).
+ // Inject empty arrays for plugin sections that were in the previous
+ // suborg config so the plugins are instantiated and can detect/remove
+ // existing entries that are no longer desired.
+ settings.removedFromSubOrgPluginSections = previousPluginSections
for (const repoName of removedRepos) {
if (settings.isRestricted(repoName)) continue
if (settings.processedRepoNames.has(repoName)) continue
@@ -616,6 +623,7 @@ class Settings {
settings.repoConfigs = await settings.getRepoConfigs(repo)
await settings.updateRepos(repo)
}
+ settings.removedFromSubOrgPluginSections = null
}
}
@@ -763,11 +771,13 @@ class Settings {
// targeting, and returns repo names present in the old targeting but absent
// from the current `this.subOrgConfigs`.
async getReposRemovedFromSubOrgTargeting (changedSubOrgs, baseRef) {
+ const emptyResult = { repos: [], previousPluginSections: [] }
if (!changedSubOrgs || changedSubOrgs.length === 0 || !baseRef) {
- return []
+ return emptyResult
}
const removedRepos = []
+ let previousPluginSections = null
for (const suborg of changedSubOrgs) {
const filePath = suborg.path
@@ -835,9 +845,24 @@ class Settings {
removedRepos.push(repoName)
}
}
+
+ // Collect plugin sections from previous config that need cleanup
+ // (these are sections that were applied by the suborg and need to be
+ // synced with empty config so existing entries are removed)
+ if (!previousPluginSections) {
+ previousPluginSections = new Set()
+ }
+ for (const key of Object.keys(previousData)) {
+ if (key in Settings.PLUGINS) {
+ previousPluginSections.add(key)
+ }
+ }
}
- return [...new Set(removedRepos)]
+ return {
+ repos: [...new Set(removedRepos)],
+ previousPluginSections: previousPluginSections ? [...previousPluginSections] : []
+ }
}
// Load a YAML file from a specific git ref, bypassing the file cache.
@@ -1708,6 +1733,18 @@ class Settings {
const overrideConfig = this.mergeDeep.mergeDeep({}, sources.org, sources.suborg, sources.repo)
+ // When processing repos removed from suborg targeting, inject empty arrays
+ // for plugin sections that were previously provided by the suborg. This
+ // ensures those plugins are instantiated and can detect/remove existing
+ // entries that are no longer desired.
+ if (this.removedFromSubOrgPluginSections && !subOrgOverrideConfig) {
+ for (const section of this.removedFromSubOrgPluginSections) {
+ if (!(section in overrideConfig)) {
+ overrideConfig[section] = []
+ }
+ }
+ }
+
this.log.debug(`consolidated config is ${JSON.stringify(overrideConfig)}`)
const childPlugins = []
diff --git a/smoke-test.js b/smoke-test.js
index 27013b4ec..40b705dc7 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -644,13 +644,13 @@ const SUBORG_EXPERT_SERVICES_PROPERTY_YML = SUBORG_EXPERT_SERVICES_YML.replace(
'suborgproperties:\n - ent-ownership: expert-services'
)
-// Suborg config that narrows targeting to only demo-repo-service2 via
+// Suborg config that narrows targeting to only the 'test' repo via
// suborgrepos. Used to test that repos dropping out of suborg targeting
// (due to targeting rule changes in the suborg.yml) have their
// suborg-applied rulesets removed.
const SUBORG_EXPERT_SERVICES_NARROW_YML = SUBORG_EXPERT_SERVICES_YML.replace(
'suborgteams:\n - expert-services-developers',
- 'suborgrepos:\n - demo-repo-service2'
+ 'suborgrepos:\n - test'
)
const REPO_DEMO_SERVICE1_ARCHIVED_YML = `# Safe-Settings Configuration
@@ -1423,7 +1423,7 @@ async function phase5Suborg () {
const branch4 = 'smoke-test-phase5-narrow-targeting'
await deleteBranch(ORG, ADMIN_REPO, branch4)
await createBranch(ORG, ADMIN_REPO, branch4)
- await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_NARROW_YML, branch4, 'Narrow suborg targeting to only demo-repo-service2')
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_NARROW_YML, branch4, 'Narrow suborg targeting to only test repo')
const pr4 = await createPR(ORG, ADMIN_REPO, 'Smoke test: narrow suborg targeting (remove demo-repo-service1)', branch4, defaultBranch)
log('Waiting for NOP check run on narrowed targeting...')
@@ -1442,10 +1442,10 @@ async function phase5Suborg () {
}, { desc: 'suborg ruleset to be removed from demo-repo-service1 after targeting narrowed', timeout: 90000 })
assert(demo1RulesetAfterNarrow === true, 'Suborg ruleset removed from demo-repo-service1 after targeting rule change')
- const demo2RulesetAfterNarrow = await poll(async () => {
- return await getRepoRuleset(ORG, 'demo-repo-service2', suborgRulesetName)
- }, { desc: 'suborg ruleset retained on demo-repo-service2 after narrowing', timeout: 60000 })
- assert(demo2RulesetAfterNarrow !== null, 'Suborg ruleset retained on demo-repo-service2 after targeting rule change')
+ const testRulesetAfterNarrow = await poll(async () => {
+ return await getRepoRuleset(ORG, 'test', suborgRulesetName)
+ }, { desc: 'suborg ruleset retained on test after narrowing', timeout: 60000 })
+ assert(testRulesetAfterNarrow !== null, 'Suborg ruleset retained on test after targeting rule change')
// Restore team-targeted config for subsequent phases
const branch5 = 'smoke-test-phase5-restore-after-narrow'
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 74de9d7ed..79d4a8a17 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1415,14 +1415,16 @@ repository:
settings = createSettings(stubConfig)
})
- it('returns empty array when no changedSubOrgs provided', async () => {
+ it('returns empty result when no changedSubOrgs provided', async () => {
const result = await settings.getReposRemovedFromSubOrgTargeting([], 'prev-sha')
- expect(result).toEqual([])
+ expect(result.repos).toEqual([])
+ expect(result.previousPluginSections).toEqual([])
})
- it('returns empty array when no baseRef provided', async () => {
+ it('returns empty result when no baseRef provided', async () => {
const result = await settings.getReposRemovedFromSubOrgTargeting([{ path: '.github/suborgs/frontend.yml' }], null)
- expect(result).toEqual([])
+ expect(result.repos).toEqual([])
+ expect(result.previousPluginSections).toEqual([])
})
it('identifies repos removed from suborgrepos targeting', async () => {
@@ -1460,8 +1462,9 @@ repository:
'prev-sha'
)
- expect(result).toContain('repo-a')
- expect(result).not.toContain('repo-b')
+ expect(result.repos).toContain('repo-a')
+ expect(result.repos).not.toContain('repo-b')
+ expect(result.previousPluginSections).toContain('teams')
})
it('identifies repos removed from suborgrepos glob targeting', async () => {
@@ -1493,9 +1496,9 @@ repository:
'prev-sha'
)
- expect(result).toContain('team-b1')
- expect(result).not.toContain('team-a1')
- expect(result).not.toContain('other')
+ expect(result.repos).toContain('team-b1')
+ expect(result.repos).not.toContain('team-a1')
+ expect(result.repos).not.toContain('other')
})
it('identifies repos removed from suborgteams targeting', async () => {
@@ -1528,8 +1531,8 @@ repository:
'prev-sha'
)
- expect(result).toContain('team-repo-2')
- expect(result).not.toContain('team-repo-1')
+ expect(result.repos).toContain('team-repo-2')
+ expect(result.repos).not.toContain('team-repo-1')
})
it('identifies repos removed from suborgproperties targeting', async () => {
@@ -1562,8 +1565,8 @@ repository:
'prev-sha'
)
- expect(result).toContain('prop-repo-2')
- expect(result).not.toContain('prop-repo-1')
+ expect(result.repos).toContain('prop-repo-2')
+ expect(result.repos).not.toContain('prop-repo-1')
})
it('deduplicates removed repos across multiple suborg files', async () => {
@@ -1593,7 +1596,7 @@ repository:
)
// Should be deduplicated
- const repoACount = result.filter(r => r === 'repo-a').length
+ const repoACount = result.repos.filter(r => r === 'repo-a').length
expect(repoACount).toBe(1)
})
@@ -1609,7 +1612,7 @@ repository:
'prev-sha'
)
- expect(result).toEqual([])
+ expect(result).toEqual({ repos: [], previousPluginSections: [] })
})
})
}) // Settings Tests
From 71e7109e4f882a81e73bce25fcb080f310f9d6cd Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 24 Jun 2026 19:21:00 -0400
Subject: [PATCH 32/67] fix: add validation checks for compiled scripts in
Settings class
---
lib/settings.js | 44 ++++++++++++++++++++++++++++++++++++++------
1 file changed, 38 insertions(+), 6 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 9242044e7..b7846c159 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -558,6 +558,7 @@ class Settings {
const settings = new Settings(nop, context, repo, config, ref, null, baseConfig)
settings.setChangedConfigTargets(changedFiles.repos, changedFiles.subOrgs)
try {
+ settings.checkValidatorsCompiled()
await settings.loadConfigs()
settings.trackChangedReposFromSubOrgConfigs()
// settings.repoConfigs = await settings.getRepoConfigs()
@@ -575,6 +576,7 @@ class Settings {
static async syncSubOrgs (nop, context, suborg, repo, config, ref) {
const settings = new Settings(nop, context, repo, config, ref, suborg)
try {
+ settings.checkValidatorsCompiled()
await settings.loadConfigs()
await settings.updateAll()
await settings.handleResults()
@@ -589,6 +591,7 @@ class Settings {
settings.setChangedConfigTargets(repos, subOrgs)
try {
+ settings.checkValidatorsCompiled()
// Track repos affected by changed suborg config files so base-config
// filtering knows which repo-level results to keep during NOP runs.
settings.subOrgConfigs = await settings.getSubOrgConfigs()
@@ -623,6 +626,7 @@ class Settings {
static async sync (nop, context, repo, config, ref) {
const settings = new Settings(nop, context, repo, config, ref)
try {
+ settings.checkValidatorsCompiled()
// Repo-yml change path: re-evaluate suborg membership for this repo if
// the applied changes (teams/custom_properties/new repo) cause it to
// newly match a suborg config.
@@ -664,21 +668,35 @@ class Settings {
this.errors = []
this.configvalidators = {}
this.overridevalidators = {}
+ // Collect any validator scripts that fail to compile. We cannot throw from
+ // the constructor: every static entry point calls `new Settings(...)`
+ // OUTSIDE its try/catch, so a throw here would bypass handleResults and the
+ // check run would never be marked as failed. Instead we record the failures
+ // here and abort the sync via checkValidatorsCompiled() inside each flow.
+ this.validatorCompileErrors = []
const overridevalidators = config.overridevalidators
if (this.isIterable(overridevalidators)) {
for (const validator of overridevalidators) {
- // eslint-disable-next-line no-new-func
- const f = new Function('baseconfig', 'overrideconfig', 'githubContext', validator.script)
- this.overridevalidators[validator.plugin] = { canOverride: f, error: validator.error }
+ try {
+ // eslint-disable-next-line no-new-func
+ const f = new Function('baseconfig', 'overrideconfig', 'githubContext', validator.script)
+ this.overridevalidators[validator.plugin] = { canOverride: f, error: validator.error }
+ } catch (e) {
+ this.validatorCompileErrors.push(`Invalid overridevalidator script for plugin '${validator.plugin}': ${e.message}`)
+ }
}
}
const configvalidators = config.configvalidators
if (this.isIterable(configvalidators)) {
for (const validator of configvalidators) {
this.log.debug(`Logging each script: ${typeof validator.script}`)
- // eslint-disable-next-line no-new-func
- const f = new Function('baseconfig', 'githubContext', validator.script)
- this.configvalidators[validator.plugin] = { isValid: f, error: validator.error }
+ try {
+ // eslint-disable-next-line no-new-func
+ const f = new Function('baseconfig', 'githubContext', validator.script)
+ this.configvalidators[validator.plugin] = { isValid: f, error: validator.error }
+ } catch (e) {
+ this.validatorCompileErrors.push(`Invalid configvalidator script for plugin '${validator.plugin}': ${e.message}`)
+ }
}
}
this.mergeDeep = new MergeDeep(this.log, this.github, [], this.configvalidators, this.overridevalidators)
@@ -797,6 +815,20 @@ class Settings {
}
}
+ // Abort the sync if any validator script failed to compile. Called at the top
+ // of every sync flow (inside the try) so the thrown error is caught by the
+ // static entry point and routed through handleResults, which marks the check
+ // run as failed. Throwing from the constructor is not an option (it runs
+ // outside the try/catch), so the failure is deferred to here.
+ checkValidatorsCompiled () {
+ if (this.validatorCompileErrors && this.validatorCompileErrors.length > 0) {
+ for (const msg of this.validatorCompileErrors) {
+ this.logError(msg)
+ }
+ throw new Error(`Aborting sync: ${this.validatorCompileErrors.length} validator script(s) failed to compile`)
+ }
+ }
+
async handleResults () {
const { payload } = this.context
From 5894eae43194d6236a995a17e3902dafdfb499ca Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 11:02:05 -0400
Subject: [PATCH 33/67] feat: add app installation plugin for managing GitHub
App repo access
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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>
---
index.js | 72 +++++
lib/appOctokitClient.js | 145 +++++++++
lib/plugins/appInstallations.js | 302 ++++++++++++++++++
lib/repoSelector.js | 159 +++++++++
lib/settings.js | 182 ++++++++++-
test/unit/lib/appOctokitClient.test.js | 110 +++++++
.../unit/lib/plugins/appInstallations.test.js | 206 ++++++++++++
test/unit/lib/repoSelector.test.js | 152 +++++++++
test/unit/lib/settings.test.js | 8 +-
9 files changed, 1330 insertions(+), 6 deletions(-)
create mode 100644 lib/appOctokitClient.js
create mode 100644 lib/plugins/appInstallations.js
create mode 100644 lib/repoSelector.js
create mode 100644 test/unit/lib/appOctokitClient.test.js
create mode 100644 test/unit/lib/plugins/appInstallations.test.js
create mode 100644 test/unit/lib/repoSelector.test.js
diff --git a/index.js b/index.js
index d3b801c54..f1d161f07 100644
--- a/index.js
+++ b/index.js
@@ -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) {
@@ -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()
+ } catch (e) {
+ robot.log.debug(`Could not create app-authenticated client for enterprise: ${e.message}`)
+ }
+ }
+ }
+
/**
* Loads the deployment config file from file system
* 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')) =>
}
})
+ // ────────────────────────────────────────────────────────────────────────
+ // 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
diff --git a/lib/appOctokitClient.js b/lib/appOctokitClient.js
new file mode 100644
index 000000000..dda221415
--- /dev/null
+++ b/lib/appOctokitClient.js
@@ -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} 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} 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}
+ */
+ 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}
+ */
+ 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
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
new file mode 100644
index 000000000..bdd03a197
--- /dev/null
+++ b/lib/plugins/appInstallations.js
@@ -0,0 +1,302 @@
+/* eslint-disable camelcase */
+const NopCommand = require('../nopcommand')
+const AppOctokitClient = require('../appOctokitClient')
+
+/**
+ * AppInstallations plugin manages which repositories are accessible to
+ * GitHub App installations in the organization.
+ *
+ * Unlike repo-targeting plugins (which extend Diffable), this plugin
+ * operates at the org level — the "target" is an app installation,
+ * not a repository.
+ *
+ * Supports:
+ * - Delta-based sync (incremental changes from config file diffs)
+ * - Full sync (compare desired state against live API state)
+ * - disable_plugins (skipped when disabled)
+ * - additive_plugins (only adds repos, never removes)
+ */
+class AppInstallations {
+ /**
+ * @param {boolean} nop - Dry-run mode
+ * @param {object} github - Octokit client (installation-authenticated)
+ * @param {object} appGithub - Octokit client (app-authenticated, for enterprise API)
+ * @param {object} repo - { owner, repo } context
+ * @param {string} enterpriseSlug - Enterprise slug from webhook payload
+ * @param {object} log - Logger
+ * @param {Array} errors - Shared errors array
+ */
+ constructor (nop, github, appGithub, repo, enterpriseSlug, log, errors) {
+ this.nop = nop
+ this.github = github
+ this.repo = repo
+ this.log = log
+ this.errors = errors || []
+ this.additive = false
+
+ if (appGithub && enterpriseSlug) {
+ this.enterpriseClient = new AppOctokitClient({
+ github: appGithub,
+ enterpriseSlug,
+ log
+ })
+ }
+ }
+
+ /**
+ * Delta-based sync: process pre-computed per-app changes.
+ *
+ * @param {Array} appChanges - Array of per-app change objects:
+ * {
+ * app_slug: string,
+ * installation_id: number,
+ * repository_selection: Set | 'all', // repos to add
+ * repository_unselection: Set, // repos to remove
+ * }
+ * @returns {Promise} NopCommand results (in nop mode) or empty
+ */
+ async syncDelta (appChanges) {
+ const results = []
+
+ if (!appChanges || appChanges.length === 0) return results
+
+ for (const change of appChanges) {
+ try {
+ const appResults = await this._processAppChange(change)
+ results.push(...appResults)
+ } catch (e) {
+ this.log.error(`Error processing app installation '${change.app_slug}': ${e.message}`)
+ this.errors.push({
+ owner: this.repo.owner,
+ repo: this.repo.repo,
+ msg: e.message,
+ plugin: 'app_installations'
+ })
+ if (this.nop) {
+ results.push(new NopCommand(
+ 'app_installations',
+ this.repo,
+ null,
+ `Error: ${e.message}`,
+ 'ERROR'
+ ))
+ }
+ }
+ }
+
+ return results
+ }
+
+ /**
+ * Full sync: compute full desired state for all managed apps,
+ * compare against live API state, and reconcile.
+ *
+ * @param {object} desiredState - Map of app_slug → { installation_id, repos: Set | 'all' }
+ * @returns {Promise} NopCommand results (in nop mode) or empty
+ */
+ async syncFull (desiredState) {
+ const results = []
+
+ if (!desiredState || Object.keys(desiredState).length === 0) return results
+
+ if (!this.enterpriseClient) {
+ const msg = 'Cannot sync app installations: enterprise client not configured. Ensure safe-settings is installed on the enterprise.'
+ this.log.error(msg)
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, msg, 'ERROR'))
+ }
+ return results
+ }
+
+ for (const [appSlug, desired] of Object.entries(desiredState)) {
+ try {
+ // Get live state
+ const liveRepos = await this.enterpriseClient.listInstallationRepos(desired.installation_id)
+ const liveRepoNames = new Set(liveRepos.map(r => r.name))
+ const liveRepoMap = new Map(liveRepos.map(r => [r.name, r.id]))
+
+ let desiredRepoNames
+ if (desired.repos === 'all') {
+ // Desired = all repos in org
+ desiredRepoNames = await this._getAllRepoNames()
+ } else {
+ desiredRepoNames = desired.repos
+ }
+
+ // Compute diff
+ const toAdd = new Set([...desiredRepoNames].filter(r => !liveRepoNames.has(r)))
+ const toRemove = this.additive
+ ? new Set() // Additive mode: never remove
+ : new Set([...liveRepoNames].filter(r => !desiredRepoNames.has(r)))
+
+ if (toAdd.size === 0 && toRemove.size === 0) {
+ this.log.debug(`App '${appSlug}': no changes needed`)
+ continue
+ }
+
+ if (this.nop) {
+ results.push(new NopCommand(
+ 'app_installations',
+ this.repo,
+ null,
+ {
+ msg: `App '${appSlug}' installation repos`,
+ additions: toAdd.size > 0 ? [...toAdd] : null,
+ modifications: null,
+ deletions: toRemove.size > 0 ? [...toRemove] : null
+ }
+ ))
+ continue
+ }
+
+ // Resolve names to IDs for additions (need to look up IDs)
+ if (toAdd.size > 0) {
+ const addIds = await this._resolveRepoIds([...toAdd])
+ await this.enterpriseClient.addReposToInstallation(desired.installation_id, addIds)
+ this.log.debug(`App '${appSlug}': added ${addIds.length} repos`)
+ }
+
+ if (toRemove.size > 0) {
+ const removeIds = [...toRemove].map(name => liveRepoMap.get(name)).filter(Boolean)
+ await this.enterpriseClient.removeReposFromInstallation(desired.installation_id, removeIds)
+ this.log.debug(`App '${appSlug}': removed ${removeIds.length} repos`)
+ }
+ } catch (e) {
+ this.log.error(`Error in full sync for app '${appSlug}': ${e.message}`)
+ this.errors.push({
+ owner: this.repo.owner,
+ repo: this.repo.repo,
+ msg: e.message,
+ plugin: 'app_installations'
+ })
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, `Error: ${e.message}`, 'ERROR'))
+ }
+ }
+ }
+
+ return results
+ }
+
+ /**
+ * Process a single app's delta change.
+ * @private
+ */
+ async _processAppChange (change) {
+ const results = []
+ const { app_slug, installation_id, repository_selection, repository_unselection } = change
+
+ if (!this.enterpriseClient) {
+ const msg = 'Cannot sync app installations: enterprise client not configured. Ensure safe-settings is installed on the enterprise.'
+ this.log.error(msg)
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, msg, 'ERROR'))
+ }
+ return results
+ }
+
+ const hasSelections = repository_selection === 'all' ||
+ (repository_selection instanceof Set && repository_selection.size > 0)
+ const hasUnselections = !this.additive &&
+ (repository_unselection instanceof Set && repository_unselection.size > 0)
+
+ if (!hasSelections && !hasUnselections) {
+ return results
+ }
+
+ // Handle "all" selection — set repository_selection to all via the API
+ if (repository_selection === 'all') {
+ if (this.nop) {
+ results.push(new NopCommand(
+ 'app_installations',
+ this.repo,
+ null,
+ {
+ msg: `App '${app_slug}': set repository_selection to 'all'`,
+ additions: ['(all repositories)'],
+ modifications: null,
+ deletions: null
+ }
+ ))
+ return results
+ }
+
+ // For "all" repos, get the full list and add all
+ const allRepoNames = await this._getAllRepoNames()
+ const liveRepos = await this.enterpriseClient.listInstallationRepos(installation_id)
+ const liveRepoNames = new Set(liveRepos.map(r => r.name))
+ const toAdd = [...allRepoNames].filter(r => !liveRepoNames.has(r))
+
+ if (toAdd.length > 0) {
+ const addIds = await this._resolveRepoIds(toAdd)
+ await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
+ this.log.debug(`App '${app_slug}': added all repos (${addIds.length} new)`)
+ }
+
+ return results
+ }
+
+ // Handle specific repos
+ if (this.nop) {
+ const additions = hasSelections ? [...repository_selection] : null
+ const deletions = hasUnselections ? [...repository_unselection] : null
+ results.push(new NopCommand(
+ 'app_installations',
+ this.repo,
+ null,
+ {
+ msg: `App '${app_slug}' installation repos`,
+ additions,
+ modifications: null,
+ deletions
+ }
+ ))
+ return results
+ }
+
+ if (hasSelections) {
+ const addIds = await this._resolveRepoIds([...repository_selection])
+ await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
+ this.log.debug(`App '${app_slug}': added ${addIds.length} repos`)
+ }
+
+ if (hasUnselections) {
+ const removeIds = await this._resolveRepoIds([...repository_unselection])
+ await this.enterpriseClient.removeReposFromInstallation(installation_id, removeIds)
+ this.log.debug(`App '${app_slug}': removed ${removeIds.length} repos`)
+ }
+
+ return results
+ }
+
+ /**
+ * Get all repo names visible to the installation.
+ * @private
+ */
+ async _getAllRepoNames () {
+ const repos = await this.github.paginate('GET /installation/repositories')
+ return new Set(repos.map(r => r.name))
+ }
+
+ /**
+ * Resolve repo names to IDs.
+ * @private
+ */
+ async _resolveRepoIds (repoNames) {
+ const ids = []
+ for (const name of repoNames) {
+ try {
+ const { data } = await this.github.repos.get({
+ owner: this.repo.owner,
+ repo: name
+ })
+ ids.push(data.id)
+ } catch (e) {
+ this.log.debug(`Could not resolve repo ID for '${name}': ${e.message}`)
+ }
+ }
+ return ids
+ }
+}
+
+module.exports = AppInstallations
diff --git a/lib/repoSelector.js b/lib/repoSelector.js
new file mode 100644
index 000000000..47b40163f
--- /dev/null
+++ b/lib/repoSelector.js
@@ -0,0 +1,159 @@
+const Glob = require('./glob')
+
+/**
+ * RepoSelector resolves a set of repository names from fixed criteria.
+ *
+ * Supported criteria:
+ * - name: explicit repo names (or glob patterns)
+ * - team: repos belonging to a GitHub team
+ * - custom_properties: repos matching custom property values
+ * - all: all repos visible to the installation
+ *
+ * @param {object} github - Authenticated Octokit client
+ * @param {string} org - Organization name
+ * @param {object} log - Logger instance
+ */
+class RepoSelector {
+ constructor (github, org, log) {
+ this.github = github
+ this.org = org
+ this.log = log
+ }
+
+ /**
+ * Resolve repos from a list of criteria. Returns a Set of repo names.
+ *
+ * @param {object} criteria - Selection criteria
+ * @param {boolean} [criteria.all] - Select all repos in the org
+ * @param {string[]} [criteria.names] - Explicit repo names or glob patterns
+ * @param {string[]} [criteria.teams] - Team slugs
+ * @param {object[]} [criteria.custom_properties] - Array of { name: value } property filters
+ * @returns {Promise>} Set of resolved repo names
+ */
+ async resolve (criteria) {
+ if (!criteria) return new Set()
+
+ // "all" takes precedence — return all repos without filtering
+ if (criteria.all) {
+ return this.getAllRepos()
+ }
+
+ const results = new Set()
+ const promises = []
+
+ if (criteria.names && Array.isArray(criteria.names)) {
+ promises.push(this.resolveByName(criteria.names))
+ }
+
+ if (criteria.teams && Array.isArray(criteria.teams)) {
+ promises.push(this.resolveByTeam(criteria.teams))
+ }
+
+ if (criteria.custom_properties && Array.isArray(criteria.custom_properties)) {
+ promises.push(this.resolveByCustomProperties(criteria.custom_properties))
+ }
+
+ const resolved = await Promise.all(promises)
+ for (const repoSet of resolved) {
+ for (const name of repoSet) {
+ results.add(name)
+ }
+ }
+
+ return results
+ }
+
+ /**
+ * Get all repos visible to the installation.
+ */
+ async getAllRepos () {
+ const repos = new Set()
+ const repositories = await this.github.paginate('GET /installation/repositories')
+ for (const repo of repositories) {
+ repos.add(repo.name)
+ }
+ return repos
+ }
+
+ /**
+ * Resolve repos by explicit name or glob pattern.
+ */
+ async resolveByName (names) {
+ const repos = new Set()
+ const hasGlobs = names.some(n => n.includes('*') || n.includes('?'))
+
+ if (hasGlobs) {
+ // Need to fetch all repos and match against globs
+ const allRepos = await this.github.paginate('GET /installation/repositories')
+ for (const name of names) {
+ const glob = new Glob(name)
+ for (const repo of allRepos) {
+ if (glob.test(repo.name)) {
+ repos.add(repo.name)
+ }
+ }
+ }
+ } else {
+ // Plain names — add directly
+ for (const name of names) {
+ repos.add(name)
+ }
+ }
+
+ return repos
+ }
+
+ /**
+ * Resolve repos by team membership.
+ */
+ async resolveByTeam (teams) {
+ const repos = new Set()
+ const teamPromises = teams.map(teamSlug => {
+ const options = this.github.rest.teams.listReposInOrg.endpoint.merge({
+ org: this.org,
+ team_slug: teamSlug,
+ per_page: 100
+ })
+ return this.github.paginate(options)
+ })
+
+ const results = await Promise.all(teamPromises)
+ for (const teamRepos of results) {
+ for (const repo of teamRepos) {
+ repos.add(repo.name)
+ }
+ }
+
+ return repos
+ }
+
+ /**
+ * Resolve repos by custom property values.
+ * Each entry in the array is an object { propertyName: propertyValue }.
+ */
+ async resolveByCustomProperties (properties) {
+ const repos = new Set()
+ const propPromises = properties.map(async (propertyFilter) => {
+ const [name] = Object.keys(propertyFilter)
+ const value = propertyFilter[name]
+
+ const query = `props.${name}:${value}`
+ const encodedQuery = encodeURIComponent(query)
+ const options = this.github.request.endpoint(
+ `/orgs/${this.org}/properties/values?repository_query=${encodedQuery}`
+ )
+ return this.github.paginate(options)
+ })
+
+ const results = await Promise.all(propPromises)
+ for (const propRepos of results) {
+ for (const repo of propRepos) {
+ repos.add(repo.repository_name)
+ }
+ }
+
+ return repos
+ }
+}
+
+module.exports = RepoSelector
diff --git a/lib/settings.js b/lib/settings.js
index bc89e016c..8f7dcb593 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -6,6 +6,8 @@ const Glob = require('./glob')
const NopCommand = require('./nopcommand')
const MergeDeep = require('./mergeDeep')
const Archive = require('./plugins/archive')
+const AppInstallations = require('./plugins/appInstallations')
+const RepoSelector = require('./repoSelector')
const DeploymentConfig = require('./deploymentConfig')
const env = require('./env')
@@ -563,6 +565,10 @@ class Settings {
settings.trackChangedReposFromSubOrgConfigs()
// settings.repoConfigs = await settings.getRepoConfigs()
await settings.updateOrg()
+ await settings.syncAppInstallations({
+ appGithub: context.appGithub,
+ enterpriseSlug: context.enterpriseSlug
+ })
await settings.updateAll()
await settings.updateChangedRepoConfigs(changedFiles.repos)
await settings.handleResults()
@@ -1404,6 +1410,176 @@ class Settings {
}
}
+ /**
+ * Sync app installations as a separate phase.
+ * In full sync mode, computes desired state for all managed apps across all
+ * config layers and reconciles against live API state.
+ * In delta mode, processes only the apps affected by changed config files.
+ *
+ * @param {object} [options]
+ * @param {object} [options.appGithub] - App-authenticated Octokit (for enterprise API)
+ * @param {string} [options.enterpriseSlug] - Enterprise slug from payload
+ * @param {Array} [options.changedAppSlugs] - App slugs affected by config changes (delta mode)
+ * @param {Array} [options.appChanges] - Pre-computed per-app changes (delta mode)
+ */
+ async syncAppInstallations (options = {}) {
+ const { appGithub, enterpriseSlug, appChanges } = options
+
+ const appInstallationsConfig = this.config.app_installations
+ if (!appInstallationsConfig || !Array.isArray(appInstallationsConfig) || appInstallationsConfig.length === 0) {
+ this.log.debug('No app_installations config found, skipping')
+ return
+ }
+
+ // Check disable_plugins
+ const stripMap = this.computeStripMap()
+ if (this.isPluginDisabledAnywhere(stripMap, 'app_installations')) {
+ this.log.debug("disable_plugins: skipping 'app_installations' plugin")
+ this.emitDisableSkip('app_installations')
+ return
+ }
+
+ if (!enterpriseSlug) {
+ const msg = 'Cannot sync app installations: enterprise slug not available in webhook payload. Ensure the webhook is from an enterprise-managed org.'
+ this.log.error(msg)
+ if (this.nop) {
+ this.appendToResults([new NopCommand('app_installations', this.repo, null, msg, 'ERROR')])
+ }
+ return
+ }
+
+ const additiveSet = this.normalizeAdditivePlugins()
+ const plugin = new AppInstallations(
+ this.nop,
+ this.github,
+ appGithub,
+ this.repo,
+ enterpriseSlug,
+ this.log,
+ this.errors
+ )
+ plugin.additive = additiveSet.has('app_installations')
+
+ let results
+ if (appChanges && appChanges.length > 0) {
+ // Delta mode: process pre-computed changes
+ results = await plugin.syncDelta(appChanges)
+ } else {
+ // Full sync mode: compute desired state from all config layers
+ const desiredState = await this._computeFullAppDesiredState(appInstallationsConfig, appGithub, enterpriseSlug)
+ results = await plugin.syncFull(desiredState)
+ }
+
+ if (this.nop && Array.isArray(results)) {
+ results.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` })
+ }
+ this.appendToResults(results)
+ }
+
+ /**
+ * Compute the full desired state for all managed apps by merging
+ * org + suborg + repo level app_installations configs.
+ * Used only in full sync mode (cron/manual).
+ * @private
+ */
+ async _computeFullAppDesiredState (orgAppInstallations, appGithub, enterpriseSlug) {
+ const AppOctokitClient = require('./appOctokitClient')
+ const desiredState = {}
+ const repoSelector = new RepoSelector(this.github, this.repo.owner, this.log)
+
+ // Get all org installations to map app_slug → installation_id
+ let orgInstallations = []
+ if (appGithub && enterpriseSlug) {
+ const enterpriseClient = new AppOctokitClient({ github: appGithub, enterpriseSlug, log: this.log })
+ try {
+ orgInstallations = await enterpriseClient.listOrgInstallations(this.repo.owner)
+ } catch (e) {
+ this.log.error(`Failed to list org installations: ${e.message}`)
+ return desiredState
+ }
+ }
+
+ const installationMap = new Map()
+ for (const inst of orgInstallations) {
+ installationMap.set(inst.app_slug, inst.id)
+ }
+
+ // Process org-level config
+ for (const appConfig of orgAppInstallations) {
+ const slug = appConfig.app_slug
+ if (!slug) continue
+
+ const installationId = installationMap.get(slug)
+ if (!installationId) {
+ this.log.debug(`App '${slug}' not found in org installations, skipping`)
+ continue
+ }
+
+ if (appConfig.repository_selection === 'all') {
+ desiredState[slug] = { installation_id: installationId, repos: 'all' }
+ } else {
+ desiredState[slug] = { installation_id: installationId, repos: new Set() }
+ }
+ }
+
+ // Overlay suborg-level configs
+ if (this.subOrgConfigs) {
+ for (const [pattern, subOrgConfig] of Object.entries(this.subOrgConfigs)) {
+ if (!subOrgConfig || !subOrgConfig.app_installations) continue
+
+ // Resolve repos for this suborg
+ const criteria = {}
+ if (subOrgConfig.suborgrepos) criteria.names = subOrgConfig.suborgrepos
+ if (subOrgConfig.suborgteams) criteria.teams = subOrgConfig.suborgteams
+ if (subOrgConfig.suborgproperties) criteria.custom_properties = subOrgConfig.suborgproperties
+
+ let suborgRepos = new Set()
+ try {
+ suborgRepos = await repoSelector.resolve(criteria)
+ } catch (e) {
+ this.log.debug(`Error resolving suborg repos for pattern '${pattern}': ${e.message}`)
+ }
+
+ for (const appConfig of subOrgConfig.app_installations) {
+ const slug = appConfig.app_slug
+ if (!slug) continue
+ if (!desiredState[slug]) {
+ const installationId = installationMap.get(slug)
+ if (!installationId) continue
+ desiredState[slug] = { installation_id: installationId, repos: new Set() }
+ }
+ // Org "all" takes precedence — don't add specific repos
+ if (desiredState[slug].repos === 'all') continue
+ for (const repo of suborgRepos) {
+ desiredState[slug].repos.add(repo)
+ }
+ }
+ }
+ }
+
+ // Overlay repo-level configs
+ if (this.repoConfigs) {
+ for (const [repoFileName, repoConfig] of Object.entries(this.repoConfigs)) {
+ if (!repoConfig || !repoConfig.app_installations) continue
+ const repoName = repoFileName.replace(/\.ya?ml$/, '')
+
+ for (const appConfig of repoConfig.app_installations) {
+ const slug = appConfig.app_slug
+ if (!slug) continue
+ if (!desiredState[slug]) {
+ const installationId = installationMap.get(slug)
+ if (!installationId) continue
+ desiredState[slug] = { installation_id: installationId, repos: new Set() }
+ }
+ if (desiredState[slug].repos === 'all') continue
+ desiredState[slug].repos.add(repoName)
+ }
+ }
+ }
+
+ return desiredState
+ }
+
async updateRepos (repo) {
this.subOrgConfigs = this.subOrgConfigs || await this.getSubOrgConfigs()
// Snapshot the set of suborg `source` paths that match this repo *before*
@@ -2335,7 +2511,8 @@ Settings.ADDITIVE_PLUGINS = new Set([
'custom_properties',
'variables',
'rulesets',
- 'custom_repository_roles'
+ 'custom_repository_roles',
+ 'app_installations'
])
Settings.PLUGINS = {
@@ -2351,7 +2528,8 @@ Settings.PLUGINS = {
environments: require('./plugins/environments'),
custom_properties: require('./plugins/custom_properties.js'),
custom_repository_roles: require('./plugins/custom_repository_roles'),
- variables: require('./plugins/variables')
+ variables: require('./plugins/variables'),
+ app_installations: require('./plugins/appInstallations')
}
module.exports = Settings
diff --git a/test/unit/lib/appOctokitClient.test.js b/test/unit/lib/appOctokitClient.test.js
new file mode 100644
index 000000000..fdd375177
--- /dev/null
+++ b/test/unit/lib/appOctokitClient.test.js
@@ -0,0 +1,110 @@
+const AppOctokitClient = require('../../../lib/appOctokitClient')
+
+describe('AppOctokitClient', () => {
+ let github
+ let log
+ let client
+
+ beforeEach(() => {
+ log = {
+ debug: jest.fn(),
+ error: jest.fn()
+ }
+
+ github = {
+ paginate: jest.fn(),
+ request: jest.fn().mockResolvedValue({ data: {} })
+ }
+ github.request.endpoint = {
+ merge: jest.fn().mockReturnValue({})
+ }
+
+ client = new AppOctokitClient({
+ github,
+ enterpriseSlug: 'my-enterprise',
+ log
+ })
+ })
+
+ describe('listOrgInstallations', () => {
+ it('returns installations filtered by org', async () => {
+ github.paginate.mockResolvedValue([
+ { id: 1, app_slug: 'app-a', account: { login: 'my-org' } },
+ { id: 2, app_slug: 'app-b', account: { login: 'other-org' } },
+ { id: 3, app_slug: 'app-c', account: { login: 'my-org' } }
+ ])
+
+ const result = await client.listOrgInstallations('my-org')
+ expect(result).toHaveLength(2)
+ expect(result[0].app_slug).toBe('app-a')
+ expect(result[1].app_slug).toBe('app-c')
+ })
+
+ it('throws descriptive error on 403', async () => {
+ github.paginate.mockRejectedValue({ status: 403, message: 'Forbidden' })
+
+ await expect(client.listOrgInstallations('my-org'))
+ .rejects.toThrow(/enterprise/)
+ })
+
+ it('throws descriptive error on 404', async () => {
+ github.paginate.mockRejectedValue({ status: 404, message: 'Not Found' })
+
+ await expect(client.listOrgInstallations('my-org'))
+ .rejects.toThrow(/enterprise/)
+ })
+ })
+
+ describe('addReposToInstallation', () => {
+ it('does nothing for empty array', async () => {
+ await client.addReposToInstallation(123, [])
+ expect(github.request).not.toHaveBeenCalled()
+ })
+
+ it('sends single batch for <= 50 repos', async () => {
+ const ids = Array.from({ length: 10 }, (_, i) => i + 1)
+ await client.addReposToInstallation(123, ids)
+ expect(github.request).toHaveBeenCalledTimes(1)
+ expect(github.request).toHaveBeenCalledWith(
+ expect.stringContaining('POST'),
+ expect.objectContaining({
+ repository_ids: ids,
+ installation_id: 123
+ })
+ )
+ })
+
+ it('batches into chunks of 50', async () => {
+ const ids = Array.from({ length: 120 }, (_, i) => i + 1)
+ await client.addReposToInstallation(123, ids)
+ expect(github.request).toHaveBeenCalledTimes(3) // 50 + 50 + 20
+ })
+ })
+
+ describe('removeReposFromInstallation', () => {
+ it('does nothing for empty array', async () => {
+ await client.removeReposFromInstallation(123, [])
+ expect(github.request).not.toHaveBeenCalled()
+ })
+
+ it('batches into chunks of 50', async () => {
+ const ids = Array.from({ length: 75 }, (_, i) => i + 1)
+ await client.removeReposFromInstallation(123, ids)
+ expect(github.request).toHaveBeenCalledTimes(2) // 50 + 25
+ })
+ })
+
+ describe('_chunk', () => {
+ it('splits array into correct chunks', () => {
+ expect(client._chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]])
+ })
+
+ it('returns single chunk for small array', () => {
+ expect(client._chunk([1, 2], 50)).toEqual([[1, 2]])
+ })
+
+ it('returns empty array for empty input', () => {
+ expect(client._chunk([], 50)).toEqual([])
+ })
+ })
+})
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
new file mode 100644
index 000000000..739ca6f91
--- /dev/null
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -0,0 +1,206 @@
+const AppInstallations = require('../../../../lib/plugins/appInstallations')
+
+describe('AppInstallations', () => {
+ let github
+ let appGithub
+ let log
+ let errors
+
+ beforeEach(() => {
+ log = {
+ debug: jest.fn(),
+ error: jest.fn()
+ }
+ errors = []
+
+ github = {
+ paginate: jest.fn(),
+ repos: {
+ get: jest.fn()
+ },
+ request: jest.fn().mockResolvedValue({ data: {} })
+ }
+ github.request.endpoint = {
+ merge: jest.fn().mockReturnValue({})
+ }
+
+ appGithub = {
+ paginate: jest.fn(),
+ request: jest.fn().mockResolvedValue({ data: {} })
+ }
+ appGithub.request.endpoint = {
+ merge: jest.fn().mockReturnValue({})
+ }
+ })
+
+ describe('syncDelta', () => {
+ it('returns empty array for no changes', async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncDelta([])
+ expect(result).toEqual([])
+ })
+
+ it('returns empty array for null changes', async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncDelta(null)
+ expect(result).toEqual([])
+ })
+
+ it('reports error when enterprise client is not configured', async () => {
+ const plugin = new AppInstallations(true, github, null, { owner: 'org', repo: 'admin' }, null, log, errors)
+ const result = await plugin.syncDelta([{
+ app_slug: 'test-app',
+ installation_id: 1,
+ repository_selection: new Set(['repo-a']),
+ repository_unselection: new Set()
+ }])
+
+ expect(result).toHaveLength(1)
+ expect(result[0].type).toBe('ERROR')
+ })
+
+ it('generates NopCommand in nop mode for specific repos', async () => {
+ // Mock enterprise client listing repos
+ appGithub.paginate.mockResolvedValue([])
+
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: new Set(['repo-a', 'repo-b']),
+ repository_unselection: new Set(['repo-c'])
+ }])
+
+ expect(result).toHaveLength(1)
+ expect(result[0].plugin).toBe('app_installations')
+ expect(result[0].action.additions).toEqual(['repo-a', 'repo-b'])
+ expect(result[0].action.deletions).toEqual(['repo-c'])
+ })
+
+ it('generates NopCommand in nop mode for "all" selection', async () => {
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: 'all',
+ repository_unselection: new Set()
+ }])
+
+ expect(result).toHaveLength(1)
+ expect(result[0].action.additions).toEqual(['(all repositories)'])
+ })
+
+ it('suppresses unselections in additive mode', async () => {
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ plugin.additive = true
+
+ const result = await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: new Set(['repo-a']),
+ repository_unselection: new Set(['repo-b'])
+ }])
+
+ expect(result).toHaveLength(1)
+ // Should only have additions, no deletions
+ expect(result[0].action.additions).toEqual(['repo-a'])
+ expect(result[0].action.deletions).toBeNull()
+ })
+
+ it('adds repos via enterprise client in non-nop mode', async () => {
+ github.repos.get
+ .mockResolvedValueOnce({ data: { id: 100 } })
+ .mockResolvedValueOnce({ data: { id: 200 } })
+
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: new Set(['repo-a', 'repo-b']),
+ repository_unselection: new Set()
+ }])
+
+ // Should have called request to add repos
+ expect(appGithub.request).toHaveBeenCalledWith(
+ expect.stringContaining('POST'),
+ expect.objectContaining({
+ repository_ids: [100, 200]
+ })
+ )
+ })
+ })
+
+ describe('syncFull', () => {
+ it('returns empty array for no desired state', async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncFull({})
+ expect(result).toEqual([])
+ })
+
+ it('reports error when enterprise client is missing', async () => {
+ const plugin = new AppInstallations(true, github, null, { owner: 'org', repo: 'admin' }, null, log, errors)
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(['repo-a']) }
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0].type).toBe('ERROR')
+ })
+
+ it('generates NopCommand with additions and deletions in nop mode', async () => {
+ // Mock listInstallationRepos (live state)
+ appGithub.paginate.mockResolvedValue([
+ { name: 'existing-repo', id: 10 },
+ { name: 'stale-repo', id: 20 }
+ ])
+
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncFull({
+ copilot: {
+ installation_id: 1,
+ repos: new Set(['existing-repo', 'new-repo'])
+ }
+ })
+
+ expect(result).toHaveLength(1)
+ expect(result[0].action.additions).toEqual(['new-repo'])
+ expect(result[0].action.deletions).toEqual(['stale-repo'])
+ })
+
+ it('suppresses deletions in additive mode during full sync', async () => {
+ appGithub.paginate.mockResolvedValue([
+ { name: 'existing-repo', id: 10 },
+ { name: 'stale-repo', id: 20 }
+ ])
+
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ plugin.additive = true
+
+ const result = await plugin.syncFull({
+ copilot: {
+ installation_id: 1,
+ repos: new Set(['existing-repo', 'new-repo'])
+ }
+ })
+
+ expect(result).toHaveLength(1)
+ expect(result[0].action.additions).toEqual(['new-repo'])
+ expect(result[0].action.deletions).toBeNull()
+ })
+
+ it('skips app when no changes needed', async () => {
+ appGithub.paginate.mockResolvedValue([
+ { name: 'repo-a', id: 10 }
+ ])
+
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncFull({
+ copilot: {
+ installation_id: 1,
+ repos: new Set(['repo-a'])
+ }
+ })
+
+ expect(result).toEqual([])
+ })
+ })
+})
diff --git a/test/unit/lib/repoSelector.test.js b/test/unit/lib/repoSelector.test.js
new file mode 100644
index 000000000..1285100b5
--- /dev/null
+++ b/test/unit/lib/repoSelector.test.js
@@ -0,0 +1,152 @@
+const RepoSelector = require('../../../lib/repoSelector')
+
+describe('RepoSelector', () => {
+ let github
+ let log
+
+ beforeEach(() => {
+ log = {
+ debug: jest.fn(),
+ error: jest.fn()
+ }
+
+ github = {
+ paginate: jest.fn(),
+ rest: {
+ teams: {
+ listReposInOrg: {
+ endpoint: {
+ merge: jest.fn().mockReturnValue({})
+ }
+ }
+ }
+ },
+ request: {
+ endpoint: jest.fn().mockReturnValue({})
+ }
+ }
+ })
+
+ describe('resolve', () => {
+ it('returns empty set for null criteria', async () => {
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve(null)
+ expect(result).toEqual(new Set())
+ })
+
+ it('returns empty set for empty criteria', async () => {
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({})
+ expect(result).toEqual(new Set())
+ })
+ })
+
+ describe('getAllRepos', () => {
+ it('returns all repo names from installation', async () => {
+ github.paginate.mockResolvedValue([
+ { name: 'repo-a' },
+ { name: 'repo-b' },
+ { name: 'repo-c' }
+ ])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({ all: true })
+ expect(result).toEqual(new Set(['repo-a', 'repo-b', 'repo-c']))
+ })
+ })
+
+ describe('resolveByName', () => {
+ it('returns explicit repo names directly', async () => {
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({ names: ['repo-a', 'repo-b'] })
+ expect(result).toEqual(new Set(['repo-a', 'repo-b']))
+ expect(github.paginate).not.toHaveBeenCalled()
+ })
+
+ it('resolves glob patterns against all repos', async () => {
+ github.paginate.mockResolvedValue([
+ { name: 'api-service' },
+ { name: 'api-gateway' },
+ { name: 'web-frontend' }
+ ])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({ names: ['api-*'] })
+ expect(result).toEqual(new Set(['api-service', 'api-gateway']))
+ })
+ })
+
+ describe('resolveByTeam', () => {
+ it('returns repos from team membership', async () => {
+ github.paginate.mockResolvedValue([
+ { name: 'team-repo-1' },
+ { name: 'team-repo-2' }
+ ])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({ teams: ['my-team'] })
+ expect(result).toEqual(new Set(['team-repo-1', 'team-repo-2']))
+ })
+
+ it('unions repos from multiple teams', async () => {
+ github.paginate
+ .mockResolvedValueOnce([{ name: 'repo-a' }, { name: 'repo-b' }])
+ .mockResolvedValueOnce([{ name: 'repo-b' }, { name: 'repo-c' }])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({ teams: ['team-1', 'team-2'] })
+ expect(result).toEqual(new Set(['repo-a', 'repo-b', 'repo-c']))
+ })
+ })
+
+ describe('resolveByCustomProperties', () => {
+ it('returns repos matching property values', async () => {
+ github.paginate.mockResolvedValue([
+ { repository_name: 'prop-repo-1' },
+ { repository_name: 'prop-repo-2' }
+ ])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({
+ custom_properties: [{ environment: 'production' }]
+ })
+ expect(result).toEqual(new Set(['prop-repo-1', 'prop-repo-2']))
+ })
+ })
+
+ describe('combined criteria', () => {
+ it('unions results from multiple criteria types', async () => {
+ // First call: teams resolution
+ github.paginate
+ .mockResolvedValueOnce([{ name: 'team-repo' }])
+ // Second call: custom properties
+ .mockResolvedValueOnce([{ repository_name: 'prop-repo' }])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({
+ names: ['explicit-repo'],
+ teams: ['my-team'],
+ custom_properties: [{ tier: 'critical' }]
+ })
+ expect(result).toEqual(new Set(['explicit-repo', 'team-repo', 'prop-repo']))
+ })
+
+ it('all=true takes precedence over other criteria', async () => {
+ github.paginate.mockResolvedValue([
+ { name: 'repo-1' },
+ { name: 'repo-2' }
+ ])
+
+ const selector = new RepoSelector(github, 'my-org', log)
+ const result = await selector.resolve({
+ all: true,
+ names: ['specific-repo'],
+ teams: ['my-team']
+ })
+ // Should return all repos, not filter by names/teams
+ expect(result).toEqual(new Set(['repo-1', 'repo-2']))
+ // paginate called once for getAllRepos, not for teams
+ expect(github.paginate).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 79d4a8a17..85c661812 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1096,11 +1096,11 @@ repository:
describe('additive_plugins', () => {
// ── Settings.ADDITIVE_PLUGINS constant ───────────────────────────────
describe('Settings.ADDITIVE_PLUGINS', () => {
- it('28. contains all 10 Diffable-extending plugin names', () => {
+ it('28. contains all 11 additive plugin names', () => {
const expected = new Set([
'labels', 'collaborators', 'teams', 'milestones', 'autolinks',
'environments', 'custom_properties', 'variables', 'rulesets',
- 'custom_repository_roles'
+ 'custom_repository_roles', 'app_installations'
])
expect(Settings.ADDITIVE_PLUGINS).toEqual(expected)
})
@@ -1126,12 +1126,12 @@ repository:
expect(result).toEqual(new Set(['labels', 'teams', 'milestones']))
})
- it('32. all 10 Diffable plugins are accepted without error', () => {
+ it('32. all 11 additive plugins are accepted without error', () => {
const all = [...Settings.ADDITIVE_PLUGINS]
const settings = createSettings({ additive_plugins: all })
const logErrorSpy = jest.spyOn(settings, 'logError').mockImplementation(() => {})
const result = settings.normalizeAdditivePlugins()
- expect(result.size).toBe(10)
+ expect(result.size).toBe(11)
expect(logErrorSpy).not.toHaveBeenCalled()
logErrorSpy.mockRestore()
})
From a51ab87782374cd35158a659b038c3ad0ebeebbe Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 12:29:24 -0400
Subject: [PATCH 34/67] fix: use enterprise installation token for app
installation API calls
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>
---
index.js | 22 ++++++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/index.js b/index.js
index f1d161f07..a1ad3fdd2 100644
--- a/index.js
+++ b/index.js
@@ -147,8 +147,9 @@ 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.
+ * 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
*/
@@ -158,9 +159,22 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
if (enterprise && enterprise.slug) {
context.enterpriseSlug = enterprise.slug
try {
- context.appGithub = await robot.auth()
+ // 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)
+ } 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 app-authenticated client for enterprise: ${e.message}`)
+ robot.log.debug(`Could not create enterprise-authenticated client: ${e.message}`)
}
}
}
From 8fee0dddfbffdeffc6894f0270822998e55bb19f Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 12:34:30 -0400
Subject: [PATCH 35/67] perf: cache enterprise installation ID to avoid
repeated lookups
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>
---
index.js | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/index.js b/index.js
index a1ad3fdd2..a91199aa6 100644
--- a/index.js
+++ b/index.js
@@ -12,6 +12,7 @@ let deploymentConfig
module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
let appSlug = 'safe-settings'
+ let cachedEnterpriseInstallationId = null
async function syncAllSettings (nop, context, repo = context.repo(), ref, baseRef, changedFiles = {}) {
try {
deploymentConfig = await loadYamlFileSystem()
@@ -159,6 +160,12 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
if (enterprise && enterprise.slug) {
context.enterpriseSlug = enterprise.slug
try {
+ // Use cached enterprise installation ID if available
+ if (cachedEnterpriseInstallationId) {
+ context.appGithub = await robot.auth(cachedEnterpriseInstallationId)
+ return
+ }
+
// Get a JWT-authenticated client to list all installations
const appGithub = await robot.auth()
const installations = await appGithub.paginate(
@@ -169,7 +176,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterprise.slug
)
if (enterpriseInstallation) {
- context.appGithub = await robot.auth(enterpriseInstallation.id)
+ cachedEnterpriseInstallationId = enterpriseInstallation.id
+ context.appGithub = await robot.auth(cachedEnterpriseInstallationId)
} else {
robot.log.debug(`No enterprise installation found for slug '${enterprise.slug}'. App installation management will not be available.`)
}
From d0be177ae64406a3f7d0e112c31121a7619e087a Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:23:53 -0400
Subject: [PATCH 36/67] Wire syncAppInstallations into syncSelectedRepos for
delta processing
- 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>
---
index.js | 3 ++
lib/settings.js | 119 ++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 119 insertions(+), 3 deletions(-)
diff --git a/index.js b/index.js
index a91199aa6..f8d2ffd59 100644
--- a/index.js
+++ b/index.js
@@ -92,6 +92,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) {
diff --git a/lib/settings.js b/lib/settings.js
index 8f7dcb593..2ea2d7313 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -655,6 +655,15 @@ class Settings {
await settings.loadConfigs()
await settings.updateAll()
}
+
+ // Sync app installations for affected apps (delta mode)
+ await settings.syncAppInstallations({
+ appGithub: context.appGithub,
+ enterpriseSlug: context.enterpriseSlug,
+ changedSubOrgs: subOrgs,
+ changedRepos: repos
+ })
+
await settings.handleResults()
} catch (error) {
settings.logError(error.message)
@@ -1423,10 +1432,14 @@ class Settings {
* @param {Array} [options.appChanges] - Pre-computed per-app changes (delta mode)
*/
async syncAppInstallations (options = {}) {
- const { appGithub, enterpriseSlug, appChanges } = options
+ const { appGithub, enterpriseSlug, appChanges, changedSubOrgs, changedRepos } = options
const appInstallationsConfig = this.config.app_installations
- if (!appInstallationsConfig || !Array.isArray(appInstallationsConfig) || appInstallationsConfig.length === 0) {
+ // Check if any layer has app_installations config (org, suborg, or repo)
+ const hasOrgConfig = appInstallationsConfig && Array.isArray(appInstallationsConfig) && appInstallationsConfig.length > 0
+ const hasChangedConfigs = (changedSubOrgs && changedSubOrgs.length > 0) || (changedRepos && changedRepos.length > 0)
+
+ if (!hasOrgConfig && !hasChangedConfigs && (!appChanges || appChanges.length === 0)) {
this.log.debug('No app_installations config found, skipping')
return
}
@@ -1462,8 +1475,16 @@ class Settings {
let results
if (appChanges && appChanges.length > 0) {
- // Delta mode: process pre-computed changes
+ // Pre-computed delta mode
results = await plugin.syncDelta(appChanges)
+ } else if (hasChangedConfigs) {
+ // Delta mode: build app changes from changed suborg/repo configs
+ const deltaChanges = await this._buildAppChangesFromDelta(appGithub, enterpriseSlug, changedSubOrgs, changedRepos)
+ if (deltaChanges.length > 0) {
+ results = await plugin.syncDelta(deltaChanges)
+ } else {
+ results = []
+ }
} else {
// Full sync mode: compute desired state from all config layers
const desiredState = await this._computeFullAppDesiredState(appInstallationsConfig, appGithub, enterpriseSlug)
@@ -1476,6 +1497,98 @@ class Settings {
this.appendToResults(results)
}
+ /**
+ * Build delta-based app changes from changed suborg/repo config files.
+ * Extracts app_installations from each changed config and computes
+ * repository_selection / repository_unselection per app.
+ * @private
+ */
+ async _buildAppChangesFromDelta (appGithub, enterpriseSlug, changedSubOrgs = [], changedRepos = []) {
+ const AppOctokitClient = require('./appOctokitClient')
+ const repoSelector = new RepoSelector(this.github, this.repo.owner, this.log)
+ const appChangeMap = new Map() // app_slug → { installation_id, repository_selection, repository_unselection }
+
+ // Get installation map (app_slug → installation_id)
+ let installationMap = new Map()
+ if (appGithub && enterpriseSlug) {
+ try {
+ const enterpriseClient = new AppOctokitClient({ github: appGithub, enterpriseSlug, log: this.log })
+ const orgInstallations = await enterpriseClient.listOrgInstallations(this.repo.owner)
+ for (const inst of orgInstallations) {
+ installationMap.set(inst.app_slug, inst.id)
+ }
+ } catch (e) {
+ this.log.error(`Failed to list org installations for delta: ${e.message}`)
+ return []
+ }
+ }
+
+ // Process changed suborg configs
+ for (const suborg of changedSubOrgs) {
+ const suborgConfig = this.subOrgConfigs && this.subOrgConfigs[suborg.repo]
+ if (!suborgConfig || !suborgConfig.app_installations) continue
+
+ // Resolve repos for this suborg's current targeting
+ const criteria = {}
+ if (suborgConfig.suborgrepos) criteria.names = suborgConfig.suborgrepos
+ if (suborgConfig.suborgteams) criteria.teams = suborgConfig.suborgteams
+ if (suborgConfig.suborgproperties) criteria.custom_properties = suborgConfig.suborgproperties
+
+ let suborgRepos = new Set()
+ try {
+ suborgRepos = await repoSelector.resolve(criteria)
+ } catch (e) {
+ this.log.debug(`Error resolving suborg repos for '${suborg.repo}': ${e.message}`)
+ }
+
+ for (const appConfig of suborgConfig.app_installations) {
+ const slug = appConfig.app_slug
+ if (!slug) continue
+ const installationId = installationMap.get(slug)
+ if (!installationId) continue
+
+ if (!appChangeMap.has(slug)) {
+ appChangeMap.set(slug, {
+ app_slug: slug,
+ installation_id: installationId,
+ repository_selection: new Set(),
+ repository_unselection: new Set()
+ })
+ }
+ const change = appChangeMap.get(slug)
+ for (const repo of suborgRepos) {
+ change.repository_selection.add(repo)
+ }
+ }
+ }
+
+ // Process changed repo configs
+ for (const repo of changedRepos) {
+ const repoConfig = this.repoConfigs &&
+ (this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`])
+ if (!repoConfig || !repoConfig.app_installations) continue
+
+ for (const appConfig of repoConfig.app_installations) {
+ const slug = appConfig.app_slug
+ if (!slug) continue
+ const installationId = installationMap.get(slug)
+ if (!installationId) continue
+
+ if (!appChangeMap.has(slug)) {
+ appChangeMap.set(slug, {
+ app_slug: slug,
+ installation_id: installationId,
+ repository_selection: new Set(),
+ repository_unselection: new Set()
+ })
+ }
+ appChangeMap.get(slug).repository_selection.add(repo.repo)
+ }
+ }
+
+ return [...appChangeMap.values()]
+ }
+
/**
* Compute the full desired state for all managed apps by merging
* org + suborg + repo level app_installations configs.
From 24fb684cb45bccb683c558abb529b4d465dcf2a8 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 23:24:30 -0400
Subject: [PATCH 37/67] Compute repository_unselection by diffing previous vs
current configs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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>
---
lib/settings.js | 175 ++++++++++++++++++++++++++++++++++--------------
1 file changed, 124 insertions(+), 51 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 2ea2d7313..575ebb3df 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -661,7 +661,8 @@ class Settings {
appGithub: context.appGithub,
enterpriseSlug: context.enterpriseSlug,
changedSubOrgs: subOrgs,
- changedRepos: repos
+ changedRepos: repos,
+ baseRef
})
await settings.handleResults()
@@ -1432,7 +1433,7 @@ class Settings {
* @param {Array} [options.appChanges] - Pre-computed per-app changes (delta mode)
*/
async syncAppInstallations (options = {}) {
- const { appGithub, enterpriseSlug, appChanges, changedSubOrgs, changedRepos } = options
+ const { appGithub, enterpriseSlug, appChanges, changedSubOrgs, changedRepos, baseRef } = options
const appInstallationsConfig = this.config.app_installations
// Check if any layer has app_installations config (org, suborg, or repo)
@@ -1479,7 +1480,7 @@ class Settings {
results = await plugin.syncDelta(appChanges)
} else if (hasChangedConfigs) {
// Delta mode: build app changes from changed suborg/repo configs
- const deltaChanges = await this._buildAppChangesFromDelta(appGithub, enterpriseSlug, changedSubOrgs, changedRepos)
+ const deltaChanges = await this._buildAppChangesFromDelta(appGithub, enterpriseSlug, changedSubOrgs, changedRepos, baseRef)
if (deltaChanges.length > 0) {
results = await plugin.syncDelta(deltaChanges)
} else {
@@ -1499,17 +1500,18 @@ class Settings {
/**
* Build delta-based app changes from changed suborg/repo config files.
- * Extracts app_installations from each changed config and computes
- * repository_selection / repository_unselection per app.
+ * Loads both current and previous (baseRef) versions of each changed config,
+ * diffs the app_installations sections, and computes repository_selection
+ * (repos to add) and repository_unselection (repos to remove) per app.
* @private
*/
- async _buildAppChangesFromDelta (appGithub, enterpriseSlug, changedSubOrgs = [], changedRepos = []) {
+ async _buildAppChangesFromDelta (appGithub, enterpriseSlug, changedSubOrgs = [], changedRepos = [], baseRef) {
const AppOctokitClient = require('./appOctokitClient')
const repoSelector = new RepoSelector(this.github, this.repo.owner, this.log)
const appChangeMap = new Map() // app_slug → { installation_id, repository_selection, repository_unselection }
// Get installation map (app_slug → installation_id)
- let installationMap = new Map()
+ const installationMap = new Map()
if (appGithub && enterpriseSlug) {
try {
const enterpriseClient = new AppOctokitClient({ github: appGithub, enterpriseSlug, log: this.log })
@@ -1523,41 +1525,87 @@ class Settings {
}
}
- // Process changed suborg configs
- for (const suborg of changedSubOrgs) {
- const suborgConfig = this.subOrgConfigs && this.subOrgConfigs[suborg.repo]
- if (!suborgConfig || !suborgConfig.app_installations) continue
+ // Helper to ensure an entry exists in the change map
+ const ensureEntry = (slug) => {
+ if (!appChangeMap.has(slug)) {
+ const installationId = installationMap.get(slug)
+ if (!installationId) return null
+ appChangeMap.set(slug, {
+ app_slug: slug,
+ installation_id: installationId,
+ repository_selection: new Set(),
+ repository_unselection: new Set()
+ })
+ }
+ return appChangeMap.get(slug)
+ }
- // Resolve repos for this suborg's current targeting
+ // Helper to resolve repos for a suborg config's targeting criteria
+ const resolveSuborgRepos = async (config) => {
+ if (!config) return new Set()
const criteria = {}
- if (suborgConfig.suborgrepos) criteria.names = suborgConfig.suborgrepos
- if (suborgConfig.suborgteams) criteria.teams = suborgConfig.suborgteams
- if (suborgConfig.suborgproperties) criteria.custom_properties = suborgConfig.suborgproperties
-
- let suborgRepos = new Set()
+ if (config.suborgrepos) criteria.names = config.suborgrepos
+ if (config.suborgteams) criteria.teams = config.suborgteams
+ if (config.suborgproperties) criteria.custom_properties = config.suborgproperties
try {
- suborgRepos = await repoSelector.resolve(criteria)
+ return await repoSelector.resolve(criteria)
} catch (e) {
- this.log.debug(`Error resolving suborg repos for '${suborg.repo}': ${e.message}`)
+ this.log.debug(`Error resolving suborg repos: ${e.message}`)
+ return new Set()
+ }
+ }
+
+ // Process changed suborg configs
+ for (const suborg of changedSubOrgs) {
+ const currentConfig = this.subOrgConfigs && this.subOrgConfigs[suborg.repo]
+ const currentApps = (currentConfig && currentConfig.app_installations) || []
+ const currentAppSlugs = new Set(currentApps.map(a => a.app_slug).filter(Boolean))
+
+ // Load previous version of this suborg config
+ let previousConfig = null
+ let previousApps = []
+ if (baseRef && suborg.path) {
+ try {
+ previousConfig = await this.loadYamlFromRef(suborg.path, baseRef)
+ previousApps = (previousConfig && previousConfig.app_installations) || []
+ } catch (e) {
+ this.log.debug(`Could not load previous suborg config for '${suborg.repo}': ${e.message}`)
+ }
}
+ const previousAppSlugs = new Set(previousApps.map(a => a.app_slug).filter(Boolean))
- for (const appConfig of suborgConfig.app_installations) {
- const slug = appConfig.app_slug
- if (!slug) continue
- const installationId = installationMap.get(slug)
- if (!installationId) continue
-
- if (!appChangeMap.has(slug)) {
- appChangeMap.set(slug, {
- app_slug: slug,
- installation_id: installationId,
- repository_selection: new Set(),
- repository_unselection: new Set()
- })
+ // Resolve repos for current and previous targeting criteria
+ const currentRepos = await resolveSuborgRepos(currentConfig)
+ const previousRepos = await resolveSuborgRepos(previousConfig)
+
+ // Apps present in current config: repos to add = currentRepos
+ for (const slug of currentAppSlugs) {
+ const entry = ensureEntry(slug)
+ if (!entry) continue
+ for (const repo of currentRepos) {
+ entry.repository_selection.add(repo)
}
- const change = appChangeMap.get(slug)
- for (const repo of suborgRepos) {
- change.repository_selection.add(repo)
+ }
+
+ // Apps removed from config: repos to unselect = previousRepos
+ for (const slug of previousAppSlugs) {
+ if (currentAppSlugs.has(slug)) continue // still present
+ const entry = ensureEntry(slug)
+ if (!entry) continue
+ for (const repo of previousRepos) {
+ entry.repository_unselection.add(repo)
+ }
+ }
+
+ // Apps still present but targeting changed: unselect repos no longer targeted
+ for (const slug of currentAppSlugs) {
+ if (!previousAppSlugs.has(slug)) continue // newly added, no unselection needed
+ const entry = ensureEntry(slug)
+ if (!entry) continue
+ for (const repo of previousRepos) {
+ if (!currentRepos.has(repo)) {
+ entry.repository_unselection.add(repo)
+ }
}
}
}
@@ -1566,27 +1614,52 @@ class Settings {
for (const repo of changedRepos) {
const repoConfig = this.repoConfigs &&
(this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`])
- if (!repoConfig || !repoConfig.app_installations) continue
+ const currentApps = (repoConfig && repoConfig.app_installations) || []
+ const currentAppSlugs = new Set(currentApps.map(a => a.app_slug).filter(Boolean))
- for (const appConfig of repoConfig.app_installations) {
- const slug = appConfig.app_slug
- if (!slug) continue
- const installationId = installationMap.get(slug)
- if (!installationId) continue
-
- if (!appChangeMap.has(slug)) {
- appChangeMap.set(slug, {
- app_slug: slug,
- installation_id: installationId,
- repository_selection: new Set(),
- repository_unselection: new Set()
- })
+ // Load previous version of this repo config
+ let previousApps = []
+ if (baseRef) {
+ const repoFilePath = `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}`)
}
- appChangeMap.get(slug).repository_selection.add(repo.repo)
+ }
+ const previousAppSlugs = new Set(previousApps.map(a => a.app_slug).filter(Boolean))
+
+ // Apps present in current config: add this repo
+ for (const slug of currentAppSlugs) {
+ const entry = ensureEntry(slug)
+ if (!entry) continue
+ entry.repository_selection.add(repo.repo)
+ }
+
+ // Apps removed from config: unselect this repo
+ for (const slug of previousAppSlugs) {
+ if (currentAppSlugs.has(slug)) continue
+ const entry = ensureEntry(slug)
+ if (!entry) continue
+ entry.repository_unselection.add(repo.repo)
}
}
- return [...appChangeMap.values()]
+ // 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
}
/**
From 4b2cec7a58b4e9b1081ffad1fceac670a5412151 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 23:30:16 -0400
Subject: [PATCH 38/67] Fix delta bugs: org-all precedence + repo config path;
update schema
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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/.yml instead of bare repos/.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>
---
lib/settings.js | 18 +++++++++++++++++-
schema/settings.json | 35 +++++++++++++++++++++++++++++++----
2 files changed, 48 insertions(+), 5 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 575ebb3df..fd944fa45 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1525,8 +1525,24 @@ class Settings {
}
}
+ // Apps configured as "all" at the org level take precedence — they must
+ // never have repos unselected by suborg/repo deltas, and adding repos is
+ // redundant since the app already targets all repos.
+ const orgAllApps = new Set()
+ const orgAppInstallations = this.config && this.config.app_installations
+ if (Array.isArray(orgAppInstallations)) {
+ for (const appConfig of orgAppInstallations) {
+ if (appConfig && appConfig.app_slug && appConfig.repository_selection === 'all') {
+ orgAllApps.add(appConfig.app_slug)
+ }
+ }
+ }
+
// Helper to ensure an entry exists in the change map
const ensureEntry = (slug) => {
+ // Org-level "all" apps are fully managed by full sync; deltas must not
+ // add or remove repos for them (org "all" takes precedence).
+ if (orgAllApps.has(slug)) return null
if (!appChangeMap.has(slug)) {
const installationId = installationMap.get(slug)
if (!installationId) return null
@@ -1620,7 +1636,7 @@ class Settings {
// Load previous version of this repo config
let previousApps = []
if (baseRef) {
- const repoFilePath = `repos/${repo.repo}.yml`
+ const repoFilePath = path.posix.join(CONFIG_PATH, 'repos', `${repo.repo}.yml`)
try {
const previousData = await this.loadYamlFromRef(repoFilePath, baseRef)
previousApps = (previousData && previousData.app_installations) || []
diff --git a/schema/settings.json b/schema/settings.json
index 57565fd83..0c349a5d4 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -234,8 +234,32 @@
}
}
},
+ "app_installations": {
+ "description": "Manage which repositories a GitHub App installation can access. The target is a GitHub App installation rather than a repository. Repo selection follows the config hierarchy: org-level settings.yml selects all repos (repository_selection: all); suborgs/*.yml selects repos by the suborg's targeting criteria; repos/*.yml adds the specific repo. Requires safe-settings to be installed on the enterprise with 'Enterprise organization installations' permission.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "app_slug"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "app_slug": {
+ "type": "string",
+ "description": "The slug of the GitHub App installation to manage."
+ },
+ "repository_selection": {
+ "type": "string",
+ "enum": [
+ "all"
+ ],
+ "description": "Only valid at the org level (settings.yml). 'all' selects every repository in the org and takes precedence over any suborg/repo-level selections."
+ }
+ }
+ }
+ },
"additive_plugins": {
- "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). Declare only in settings.yml (org level) to keep behavior consistent across all repos.",
+ "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). The app_installations plugin also honors additive mode (only adds repos to installations, never removes). Declare only in settings.yml (org level) to keep behavior consistent across all repos.",
"type": "array",
"items": {
"type": "string",
@@ -249,7 +273,8 @@
"custom_properties",
"variables",
"rulesets",
- "custom_repository_roles"
+ "custom_repository_roles",
+ "app_installations"
]
}
},
@@ -274,7 +299,8 @@
"custom_properties",
"custom_repository_roles",
"variables",
- "archive"
+ "archive",
+ "app_installations"
]
},
{
@@ -300,7 +326,8 @@
"custom_properties",
"custom_repository_roles",
"variables",
- "archive"
+ "archive",
+ "app_installations"
]
},
"target": {
From b731d54aa54f9ac639c7a46a447dc90d103783a8 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Thu, 25 Jun 2026 23:41:17 -0400
Subject: [PATCH 39/67] Address app installation review: remove bad drift
handler, fix ordering, 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>
---
README.md | 103 +++++++++++++++++-
index.js | 32 ++----
lib/plugins/appInstallations.js | 28 ++---
.../unit/lib/plugins/appInstallations.test.js | 32 +++---
4 files changed, 144 insertions(+), 51 deletions(-)
diff --git a/README.md b/README.md
index ff1f0237f..86314547e 100644
--- a/README.md
+++ b/README.md
@@ -552,7 +552,8 @@ plugins for a given scope. Each entry is either:
Valid plugin names: `repository`, `labels`, `collaborators`, `teams`,
`milestones`, `branches`, `autolinks`, `validator`, `rulesets`, `environments`,
-`custom_properties`, `custom_repository_roles`, `variables`, `archive`.
+`custom_properties`, `custom_repository_roles`, `variables`, `archive`,
+`app_installations`.
#### Strip matrix (which source layers are removed before merge)
@@ -661,6 +662,105 @@ additive_plugins:
- collaborators
```
+### App installation management (`app_installations`)
+
+Most safe-settings plugins target a **repository**. The `app_installations`
+plugin is different: its target is a **GitHub App installation**. It lets you
+declaratively manage *which repositories a GitHub App can access* (the app's
+`repository_selection`), using the same `org` → `suborg` → `repo` config
+hierarchy you already use for repository settings.
+
+This is useful for controlling, as code, which repos apps such as Copilot,
+Dependabot, or your own internal apps are installed on across the org.
+
+#### Prerequisites
+
+- Safe-settings must be installed on the **enterprise** with the **Enterprise
+ organization installations** permission (see the
+ [Enterprise organization installations API](https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/organization-installations)).
+ Managing app installations requires an enterprise-level token; the regular
+ org installation token is not sufficient. If safe-settings is not installed
+ on the enterprise with this permission, app installation sync is reported as
+ an error and skipped.
+- The enterprise slug is read from the webhook event payload
+ (`payload.enterprise.slug`); no extra environment variable is required.
+
+#### How repository selection is resolved
+
+The config layer where `app_installations` is declared determines which repos
+are selected for the app:
+
+| Layer | File | Repos selected for the app |
+| --- | --- | --- |
+| Org | `settings.yml` | All repos in the org (`repository_selection: all`) |
+| Suborg | `suborgs/*.yml` | Repos matching the suborg's targeting (`suborgrepos`, `suborgteams`, `suborgproperties`) |
+| Repo | `repos/.yml` | That specific repo |
+
+> [!important]
+> An app configured with `repository_selection: all` at the **org** level takes
+> precedence. Suborg/repo-level selections for that same app are ignored, and
+> repos are never removed from it by incremental (suborg/repo) changes — it is
+> reconciled only by the full (scheduled) sync.
+
+#### Examples
+
+Org-level `settings.yml` — give an app access to **all** repos in the org:
+
+```yaml
+app_installations:
+ - app_slug: my-internal-app
+ repository_selection: all
+```
+
+Suborg-level `suborgs/backend.yml` — give an app access to the repos targeted
+by this suborg (here, all repos with the `Team=backend` custom property):
+
+```yaml
+suborgproperties:
+ - Team: backend
+app_installations:
+ - app_slug: my-internal-app
+```
+
+Repo-level `repos/my-repo.yml` — add this specific repo to the app:
+
+```yaml
+app_installations:
+ - app_slug: my-internal-app
+```
+
+Removing an app from a suborg/repo config (or changing the suborg's targeting)
+removes the affected repos from that app on the next sync, unless another layer
+still selects them.
+
+#### Sync behavior
+
+- **Incremental (delta) sync** runs when a `suborgs/*.yml` or `repos/*.yml`
+ file changes. Only the apps affected by the changed file are reconciled: the
+ previous version of the file is compared with the new one to compute repos to
+ add (`repository_selection`) and repos to remove (`repository_unselection`).
+ Removals are applied before additions, so a repo removed by one config and
+ added by another ends up present.
+- **Full sync** runs on the schedule (cron), on manual sync, and when
+ `settings.yml` changes. It recomputes the full desired state for every managed
+ app across all layers and reconciles it against the live installation state.
+ This is the mechanism that corrects any configuration drift.
+- Add/remove operations are automatically batched in chunks of 50 repos (the
+ API limit).
+
+> [!note]
+> Drift on managed apps is reconciled by the **full (cron) sync**, not by
+> webhooks. A GitHub App only receives `installation` repository events for its
+> *own* installation, so safe-settings cannot detect — via webhooks — when a
+> human changes another app's repository access. Keep the scheduled sync enabled
+> for timely drift correction.
+
+#### Disabling and additive mode
+
+`app_installations` honors both [`disable_plugins`](#disabling-plugins-disable_plugins)
+and [`additive_plugins`](#additive-plugins-additive_plugins). In additive mode
+the plugin only **adds** repos to installations and never removes them.
+
### The Settings Files
The settings files can be used to set the policies at the `org`, `suborg` or `repo` level.
@@ -680,6 +780,7 @@ The following can be configured:
- `Repository name validation` using regex pattern
- `Rulesets`
- `Environments` - wait timer, required reviewers, prevent self review, protected branches deployment branch policy, custom deployment branch policy, variables, deployment protection rules
+- `App installations` - which repositories a GitHub App installation can access (see [App installation management](#app-installation-management-app_installations))
See [`docs/sample-settings/settings.yml`](docs/sample-settings/settings.yml) for a sample settings file.
diff --git a/index.js b/index.js
index f8d2ffd59..3b512282a 100644
--- a/index.js
+++ b/index.js
@@ -531,32 +531,16 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
})
// ────────────────────────────────────────────────────────────────────────
- // App installation drift detection handlers
+ // 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,
+ // Dependabot) whose repository access safe-settings controls. They cannot
+ // detect drift on managed apps, so drift is reconciled by the scheduled
+ // (cron) full sync instead.
// ────────────────────────────────────────────────────────────────────────
- 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
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index bdd03a197..8af816746 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -149,18 +149,20 @@ class AppInstallations {
continue
}
- // Resolve names to IDs for additions (need to look up IDs)
- if (toAdd.size > 0) {
- const addIds = await this._resolveRepoIds([...toAdd])
- await this.enterpriseClient.addReposToInstallation(desired.installation_id, addIds)
- this.log.debug(`App '${appSlug}': added ${addIds.length} repos`)
- }
-
+ // Resolve names to IDs. Process removals first, then additions, so a
+ // repo that should be both removed (by one config) and added (by
+ // another) ends up present.
if (toRemove.size > 0) {
const removeIds = [...toRemove].map(name => liveRepoMap.get(name)).filter(Boolean)
await this.enterpriseClient.removeReposFromInstallation(desired.installation_id, removeIds)
this.log.debug(`App '${appSlug}': removed ${removeIds.length} repos`)
}
+
+ if (toAdd.size > 0) {
+ const addIds = await this._resolveRepoIds([...toAdd])
+ await this.enterpriseClient.addReposToInstallation(desired.installation_id, addIds)
+ this.log.debug(`App '${appSlug}': added ${addIds.length} repos`)
+ }
} catch (e) {
this.log.error(`Error in full sync for app '${appSlug}': ${e.message}`)
this.errors.push({
@@ -254,18 +256,18 @@ class AppInstallations {
return results
}
- if (hasSelections) {
- const addIds = await this._resolveRepoIds([...repository_selection])
- await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
- this.log.debug(`App '${app_slug}': added ${addIds.length} repos`)
- }
-
if (hasUnselections) {
const removeIds = await this._resolveRepoIds([...repository_unselection])
await this.enterpriseClient.removeReposFromInstallation(installation_id, removeIds)
this.log.debug(`App '${app_slug}': removed ${removeIds.length} repos`)
}
+ if (hasSelections) {
+ const addIds = await this._resolveRepoIds([...repository_selection])
+ await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
+ this.log.debug(`App '${app_slug}': added ${addIds.length} repos`)
+ }
+
return results
}
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
index 739ca6f91..6e7333f1b 100644
--- a/test/unit/lib/plugins/appInstallations.test.js
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -107,26 +107,32 @@ describe('AppInstallations', () => {
expect(result[0].action.deletions).toBeNull()
})
- it('adds repos via enterprise client in non-nop mode', async () => {
- github.repos.get
- .mockResolvedValueOnce({ data: { id: 100 } })
- .mockResolvedValueOnce({ data: { id: 200 } })
+ it('processes unselections before selections in non-nop mode', async () => {
+ // repo-a (add) resolves to id 100; repo-b (remove) resolves to id 200
+ github.repos.get.mockImplementation(({ repo }) => {
+ if (repo === 'repo-a') return Promise.resolve({ data: { id: 100 } })
+ if (repo === 'repo-b') return Promise.resolve({ data: { id: 200 } })
+ return Promise.resolve({ data: { id: 0 } })
+ })
+
+ const callOrder = []
+ appGithub.request.mockImplementation((route) => {
+ if (route.startsWith('DELETE')) callOrder.push('remove')
+ if (route.startsWith('POST')) callOrder.push('add')
+ return Promise.resolve({ data: {} })
+ })
const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
await plugin.syncDelta([{
app_slug: 'copilot',
installation_id: 1,
- repository_selection: new Set(['repo-a', 'repo-b']),
- repository_unselection: new Set()
+ repository_selection: new Set(['repo-a']),
+ repository_unselection: new Set(['repo-b'])
}])
- // Should have called request to add repos
- expect(appGithub.request).toHaveBeenCalledWith(
- expect.stringContaining('POST'),
- expect.objectContaining({
- repository_ids: [100, 200]
- })
- )
+ // Removal must be applied before addition so a repo removed by one
+ // config and added by another ends up present.
+ expect(callOrder).toEqual(['remove', 'add'])
})
})
From 9a25bdcebbf8d81cef8db70cc1a7d2d3c3e223ce Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 26 Jun 2026 00:01:45 -0400
Subject: [PATCH 40/67] Skip redundant app installation churn for unchanged
delta
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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>
---
lib/settings.js | 34 ++++++-----
test/unit/lib/settings.test.js | 102 +++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+), 14 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index fd944fa45..590071610 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1594,8 +1594,9 @@ class Settings {
const currentRepos = await resolveSuborgRepos(currentConfig)
const previousRepos = await resolveSuborgRepos(previousConfig)
- // Apps present in current config: repos to add = currentRepos
+ // App newly added to this suborg: select all currently targeted repos
for (const slug of currentAppSlugs) {
+ if (previousAppSlugs.has(slug)) continue
const entry = ensureEntry(slug)
if (!entry) continue
for (const repo of currentRepos) {
@@ -1603,9 +1604,9 @@ class Settings {
}
}
- // Apps removed from config: repos to unselect = previousRepos
+ // App removed from this suborg: unselect all previously targeted repos
for (const slug of previousAppSlugs) {
- if (currentAppSlugs.has(slug)) continue // still present
+ if (currentAppSlugs.has(slug)) continue
const entry = ensureEntry(slug)
if (!entry) continue
for (const repo of previousRepos) {
@@ -1613,15 +1614,17 @@ class Settings {
}
}
- // Apps still present but targeting changed: unselect repos no longer targeted
- for (const slug of currentAppSlugs) {
- if (!previousAppSlugs.has(slug)) continue // newly added, no unselection needed
- const entry = ensureEntry(slug)
- if (!entry) continue
- for (const repo of previousRepos) {
- if (!currentRepos.has(repo)) {
- entry.repository_unselection.add(repo)
- }
+ // App present in both: only act on the targeting diff. If the targeting
+ // is unchanged, skip entirely to avoid redundant churn.
+ const addedRepos = [...currentRepos].filter(r => !previousRepos.has(r))
+ const removedRepos = [...previousRepos].filter(r => !currentRepos.has(r))
+ if (addedRepos.length > 0 || removedRepos.length > 0) {
+ for (const slug of currentAppSlugs) {
+ if (!previousAppSlugs.has(slug)) continue // handled as "newly added" above
+ const entry = ensureEntry(slug)
+ if (!entry) continue
+ for (const repo of addedRepos) entry.repository_selection.add(repo)
+ for (const repo of removedRepos) entry.repository_unselection.add(repo)
}
}
}
@@ -1646,14 +1649,17 @@ class Settings {
}
const previousAppSlugs = new Set(previousApps.map(a => a.app_slug).filter(Boolean))
- // Apps present in current config: add this repo
+ // App newly added to this repo config: select this repo. If the app was
+ // already present in the previous version, its selection is unchanged —
+ // skip to avoid redundant churn.
for (const slug of currentAppSlugs) {
+ if (previousAppSlugs.has(slug)) continue
const entry = ensureEntry(slug)
if (!entry) continue
entry.repository_selection.add(repo.repo)
}
- // Apps removed from config: unselect this repo
+ // App removed from this repo config: unselect this repo
for (const slug of previousAppSlugs) {
if (currentAppSlugs.has(slug)) continue
const entry = ensureEntry(slug)
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 85c661812..32fe12471 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1615,4 +1615,106 @@ repository:
expect(result).toEqual({ repos: [], previousPluginSections: [] })
})
})
+
+ describe('_buildAppChangesFromDelta', () => {
+ let settings
+ const AppOctokitClient = require('../../../lib/appOctokitClient')
+ const RepoSelector = require('../../../lib/repoSelector')
+
+ beforeEach(() => {
+ stubConfig = { restrictedRepos: {} }
+ settings = createSettings(stubConfig)
+ // Map app slug -> installation id
+ jest.spyOn(AppOctokitClient.prototype, 'listOrgInstallations').mockResolvedValue([
+ { app_slug: 'my-app', id: 42 }
+ ])
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ it('skips an app when suborg targeting and app_installations are unchanged', async () => {
+ // Same targeting resolves to the same repos in both versions
+ jest.spyOn(RepoSelector.prototype, 'resolve').mockResolvedValue(new Set(['repo-a', 'repo-b']))
+
+ // Current suborg config: app present
+ settings.subOrgConfigs = {
+ frontend: {
+ suborgrepos: ['repo-a', 'repo-b'],
+ app_installations: [{ app_slug: 'my-app' }]
+ }
+ }
+ // Previous version (baseRef): identical app_installations
+ settings.loadYamlFromRef = jest.fn().mockResolvedValue({
+ suborgrepos: ['repo-a', 'repo-b'],
+ app_installations: [{ app_slug: 'my-app' }]
+ })
+
+ const result = await settings._buildAppChangesFromDelta(
+ settings.github,
+ 'my-enterprise',
+ [{ repo: 'frontend', path: '.github/suborgs/frontend.yml' }],
+ [],
+ 'prev-sha'
+ )
+
+ // No churn: nothing to add or remove
+ expect(result).toEqual([])
+ })
+
+ it('emits only the targeting diff when suborg repos change', async () => {
+ // previous: repo-a, repo-b ; current: repo-b, repo-c
+ jest.spyOn(RepoSelector.prototype, 'resolve')
+ .mockResolvedValueOnce(new Set(['repo-b', 'repo-c'])) // current
+ .mockResolvedValueOnce(new Set(['repo-a', 'repo-b'])) // previous
+
+ settings.subOrgConfigs = {
+ frontend: {
+ suborgrepos: ['repo-b', 'repo-c'],
+ app_installations: [{ app_slug: 'my-app' }]
+ }
+ }
+ settings.loadYamlFromRef = jest.fn().mockResolvedValue({
+ suborgrepos: ['repo-a', 'repo-b'],
+ app_installations: [{ app_slug: 'my-app' }]
+ })
+
+ const result = await settings._buildAppChangesFromDelta(
+ settings.github,
+ 'my-enterprise',
+ [{ repo: 'frontend', path: '.github/suborgs/frontend.yml' }],
+ [],
+ 'prev-sha'
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0].app_slug).toBe('my-app')
+ expect(result[0].repository_selection.sort()).toEqual(['repo-c'])
+ expect(result[0].repository_unselection.sort()).toEqual(['repo-a'])
+ })
+
+ 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' }]
+ }
+ settings.subOrgConfigs = {
+ frontend: { suborgrepos: ['repo-a'], app_installations: [{ app_slug: 'my-app' }] }
+ }
+ settings.loadYamlFromRef = jest.fn().mockResolvedValue({})
+
+ const result = await settings._buildAppChangesFromDelta(
+ settings.github,
+ 'my-enterprise',
+ [{ repo: 'frontend', path: '.github/suborgs/frontend.yml' }],
+ [],
+ 'prev-sha'
+ )
+
+ expect(result).toEqual([])
+ })
+ })
}) // Settings Tests
From eacef7f5a723380204d062cafaf2dee5dbb9768a Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 26 Jun 2026 00:18:49 -0400
Subject: [PATCH 41/67] Use correct Enterprise Org Installations API (names,
toggle, PATCH add/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>
---
lib/appOctokitClient.js | 118 +++++++---
lib/plugins/appInstallations.js | 210 +++++++++---------
lib/settings.js | 8 +
test/unit/lib/appOctokitClient.test.js | 72 ++++--
.../unit/lib/plugins/appInstallations.test.js | 56 ++++-
5 files changed, 299 insertions(+), 165 deletions(-)
diff --git a/lib/appOctokitClient.js b/lib/appOctokitClient.js
index dda221415..c888f97eb 100644
--- a/lib/appOctokitClient.js
+++ b/lib/appOctokitClient.js
@@ -1,18 +1,27 @@
const BATCH_SIZE = 50
+const API_VERSION = '2026-03-10'
/**
- * 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.
+ * AppOctokitClient wraps an Octokit client authenticated as the GitHub App at
+ * the enterprise level and provides methods for managing GitHub App
+ * installation repository access via the Enterprise Organization Installations
+ * API.
+ *
+ * All endpoints are org-scoped under
+ * `/enterprises/{enterprise}/apps/organizations/{org}/...` and operate on
+ * repository **names** (not IDs). Add/remove are capped at 50 repos per call
+ * and are auto-batched here.
*
* Prerequisites:
- * - safe-settings must be installed on the enterprise with
+ * - safe-settings must be installed on the enterprise with the
* "Enterprise organization installations" permission.
* - The enterprise slug is obtained from the webhook event payload
* (payload.enterprise.slug).
*
+ * @see https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/organization-installations
+ *
* @param {object} options
- * @param {object} options.github - Octokit client authenticated as the app (via robot.auth())
+ * @param {object} options.github - Octokit client authenticated as the app at the enterprise installation
* @param {string} options.enterpriseSlug - Enterprise slug from webhook payload
* @param {object} options.log - Logger instance
*/
@@ -24,8 +33,9 @@ class AppOctokitClient {
}
/**
- * List all app installations in the enterprise for a given org.
- * Returns array of installation objects with { id, app_slug, app_id, ... }
+ * List the GitHub App installations on an enterprise-owned organization.
+ * Returns array of installation objects with
+ * { id, app_slug, client_id, repository_selection, ... }
*
* @param {string} org - Organization login name
* @returns {Promise} List of installations
@@ -33,17 +43,14 @@ class AppOctokitClient {
async listOrgInstallations (org) {
try {
const options = this.github.request.endpoint.merge(
- 'GET /enterprises/{enterprise}/apps/installations',
+ 'GET /enterprises/{enterprise}/apps/organizations/{org}/installations',
{
enterprise: this.enterpriseSlug,
- headers: { 'X-GitHub-Api-Version': '2026-03-10' }
+ org,
+ headers: { 'X-GitHub-Api-Version': API_VERSION }
}
)
- const installations = await this.github.paginate(options)
- // Filter to installations for the specified org
- return installations.filter(i =>
- i.account && i.account.login === org
- )
+ return await this.github.paginate(options)
} catch (e) {
if (e.status === 403 || e.status === 404) {
throw new Error(
@@ -55,22 +62,25 @@ class AppOctokitClient {
}
/**
- * List repositories accessible to an app installation.
+ * List repositories accessible to an app installation on an org.
+ * Returns array of { id, name, full_name }.
*
+ * @param {string} org - Organization login name
* @param {number} installationId - The installation ID
* @returns {Promise} List of repository objects
*/
- async listInstallationRepos (installationId) {
+ async listInstallationRepos (org, installationId) {
try {
const options = this.github.request.endpoint.merge(
- 'GET /enterprises/{enterprise}/apps/installations/{installation_id}/repositories',
+ 'GET /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories',
{
enterprise: this.enterpriseSlug,
+ org,
installation_id: installationId,
- headers: { 'X-GitHub-Api-Version': '2026-03-10' }
+ headers: { 'X-GitHub-Api-Version': API_VERSION }
}
)
- return this.github.paginate(options)
+ return await this.github.paginate(options)
} catch (e) {
this.log.error(`Error listing repos for installation ${installationId}: ${e.message}`)
throw e
@@ -78,52 +88,86 @@ class AppOctokitClient {
}
/**
- * Grant repository access to an app installation.
+ * Toggle an installation's repository access between 'all' and 'selected'.
+ * When setting 'selected', `repositories` (names) must contain at least one
+ * repo. When setting 'all', `repositories` must be omitted.
+ *
+ * @param {string} org - Organization login name
+ * @param {number} installationId - The installation ID
+ * @param {('all'|'selected')} selection - Desired repository selection
+ * @param {string[]} [repositories] - Repo names (required for 'selected')
+ * @returns {Promise}
+ */
+ async setRepositorySelection (org, installationId, selection, repositories) {
+ const params = {
+ enterprise: this.enterpriseSlug,
+ org,
+ installation_id: installationId,
+ repository_selection: selection,
+ headers: { 'X-GitHub-Api-Version': API_VERSION }
+ }
+ if (selection === 'selected') {
+ params.repositories = repositories || []
+ }
+ this.log.debug(`Setting repository_selection='${selection}' for installation ${installationId}`)
+ await this.github.request(
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories',
+ params
+ )
+ }
+
+ /**
+ * Grant repository access to an org installation.
* Automatically batches into chunks of 50 (API limit).
*
+ * @param {string} org - Organization login name
* @param {number} installationId - The installation ID
- * @param {number[]} repositoryIds - Array of repository IDs to add
+ * @param {string[]} repositoryNames - Repo names to add
* @returns {Promise}
*/
- async addReposToInstallation (installationId, repositoryIds) {
- if (!repositoryIds || repositoryIds.length === 0) return
+ async addReposToInstallation (org, installationId, repositoryNames) {
+ if (!repositoryNames || repositoryNames.length === 0) return
- const batches = this._chunk(repositoryIds, BATCH_SIZE)
- for (const batch of batches) {
+ for (const batch of this._chunk(repositoryNames, BATCH_SIZE)) {
this.log.debug(`Adding ${batch.length} repos to installation ${installationId}`)
await this.github.request(
- 'POST /enterprises/{enterprise}/apps/installations/{installation_id}/repositories',
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories/add',
{
enterprise: this.enterpriseSlug,
+ org,
installation_id: installationId,
- repository_ids: batch,
- headers: { 'X-GitHub-Api-Version': '2026-03-10' }
+ repositories: batch,
+ headers: { 'X-GitHub-Api-Version': API_VERSION }
}
)
}
}
/**
- * Remove repository access from an app installation.
+ * Remove repository access from an org installation.
* Automatically batches into chunks of 50 (API limit).
*
+ * Note: the API returns 422 if you attempt to remove repos from an
+ * installation set to 'all', or remove the last remaining repository.
+ *
+ * @param {string} org - Organization login name
* @param {number} installationId - The installation ID
- * @param {number[]} repositoryIds - Array of repository IDs to remove
+ * @param {string[]} repositoryNames - Repo names to remove
* @returns {Promise}
*/
- async removeReposFromInstallation (installationId, repositoryIds) {
- if (!repositoryIds || repositoryIds.length === 0) return
+ async removeReposFromInstallation (org, installationId, repositoryNames) {
+ if (!repositoryNames || repositoryNames.length === 0) return
- const batches = this._chunk(repositoryIds, BATCH_SIZE)
- for (const batch of batches) {
+ for (const batch of this._chunk(repositoryNames, BATCH_SIZE)) {
this.log.debug(`Removing ${batch.length} repos from installation ${installationId}`)
await this.github.request(
- 'DELETE /enterprises/{enterprise}/apps/installations/{installation_id}/repositories',
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories/remove',
{
enterprise: this.enterpriseSlug,
+ org,
installation_id: installationId,
- repository_ids: batch,
- headers: { 'X-GitHub-Api-Version': '2026-03-10' }
+ repositories: batch,
+ headers: { 'X-GitHub-Api-Version': API_VERSION }
}
)
}
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index 8af816746..4e008a863 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -30,6 +30,7 @@ class AppInstallations {
this.nop = nop
this.github = github
this.repo = repo
+ this.org = repo.owner
this.log = log
this.errors = errors || []
this.additive = false
@@ -91,7 +92,9 @@ class AppInstallations {
* Full sync: compute full desired state for all managed apps,
* compare against live API state, and reconcile.
*
- * @param {object} desiredState - Map of app_slug → { installation_id, repos: Set | 'all' }
+ * @param {object} desiredState - Map of app_slug → {
+ * installation_id, repos: Set | 'all', current_selection: 'all' | 'selected'
+ * }
* @returns {Promise} NopCommand results (in nop mode) or empty
*/
async syncFull (desiredState) {
@@ -110,59 +113,8 @@ class AppInstallations {
for (const [appSlug, desired] of Object.entries(desiredState)) {
try {
- // Get live state
- const liveRepos = await this.enterpriseClient.listInstallationRepos(desired.installation_id)
- const liveRepoNames = new Set(liveRepos.map(r => r.name))
- const liveRepoMap = new Map(liveRepos.map(r => [r.name, r.id]))
-
- let desiredRepoNames
- if (desired.repos === 'all') {
- // Desired = all repos in org
- desiredRepoNames = await this._getAllRepoNames()
- } else {
- desiredRepoNames = desired.repos
- }
-
- // Compute diff
- const toAdd = new Set([...desiredRepoNames].filter(r => !liveRepoNames.has(r)))
- const toRemove = this.additive
- ? new Set() // Additive mode: never remove
- : new Set([...liveRepoNames].filter(r => !desiredRepoNames.has(r)))
-
- if (toAdd.size === 0 && toRemove.size === 0) {
- this.log.debug(`App '${appSlug}': no changes needed`)
- continue
- }
-
- if (this.nop) {
- results.push(new NopCommand(
- 'app_installations',
- this.repo,
- null,
- {
- msg: `App '${appSlug}' installation repos`,
- additions: toAdd.size > 0 ? [...toAdd] : null,
- modifications: null,
- deletions: toRemove.size > 0 ? [...toRemove] : null
- }
- ))
- continue
- }
-
- // Resolve names to IDs. Process removals first, then additions, so a
- // repo that should be both removed (by one config) and added (by
- // another) ends up present.
- if (toRemove.size > 0) {
- const removeIds = [...toRemove].map(name => liveRepoMap.get(name)).filter(Boolean)
- await this.enterpriseClient.removeReposFromInstallation(desired.installation_id, removeIds)
- this.log.debug(`App '${appSlug}': removed ${removeIds.length} repos`)
- }
-
- if (toAdd.size > 0) {
- const addIds = await this._resolveRepoIds([...toAdd])
- await this.enterpriseClient.addReposToInstallation(desired.installation_id, addIds)
- this.log.debug(`App '${appSlug}': added ${addIds.length} repos`)
- }
+ const appResults = await this._reconcileApp(appSlug, desired)
+ results.push(...appResults)
} catch (e) {
this.log.error(`Error in full sync for app '${appSlug}': ${e.message}`)
this.errors.push({
@@ -180,6 +132,101 @@ class AppInstallations {
return results
}
+ /**
+ * Reconcile a single app's desired state against its live installation state.
+ * @private
+ */
+ async _reconcileApp (appSlug, desired) {
+ const results = []
+ const { installation_id, repos, current_selection } = desired
+
+ // Desired = all repos in the org → toggle the installation to 'all'.
+ if (repos === 'all') {
+ if (current_selection === 'all') {
+ this.log.debug(`App '${appSlug}': already set to all repositories, no change`)
+ return results
+ }
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, {
+ msg: `App '${appSlug}': set repository_selection to 'all'`,
+ additions: ['(all repositories)'],
+ modifications: null,
+ deletions: null
+ }))
+ return results
+ }
+ await this.enterpriseClient.setRepositorySelection(this.org, installation_id, 'all')
+ this.log.debug(`App '${appSlug}': set repository_selection to 'all'`)
+ return results
+ }
+
+ const desiredNames = repos instanceof Set ? repos : new Set(repos)
+
+ // Installation is currently 'all' but desired is a specific set → switch
+ // the installation to 'selected' with the desired repos. In additive mode
+ // we must not narrow access, so leave 'all' untouched.
+ if (current_selection === 'all') {
+ if (this.additive) {
+ this.log.debug(`App '${appSlug}': additive mode, leaving 'all' selection untouched`)
+ return results
+ }
+ if (desiredNames.size === 0) {
+ // Cannot set 'selected' with an empty list; nothing safe to do here.
+ this.log.debug(`App '${appSlug}': desired set is empty, leaving 'all' selection untouched`)
+ return results
+ }
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, {
+ msg: `App '${appSlug}': narrow repository_selection from 'all' to selected`,
+ additions: [...desiredNames],
+ modifications: null,
+ deletions: ['(all repositories)']
+ }))
+ return results
+ }
+ await this.enterpriseClient.setRepositorySelection(this.org, installation_id, 'selected', [...desiredNames])
+ this.log.debug(`App '${appSlug}': set repository_selection to 'selected' with ${desiredNames.size} repos`)
+ return results
+ }
+
+ // Installation is 'selected' → diff against live repos and add/remove.
+ const liveRepos = await this.enterpriseClient.listInstallationRepos(this.org, installation_id)
+ const liveRepoNames = new Set(liveRepos.map(r => r.name))
+
+ const toAdd = [...desiredNames].filter(r => !liveRepoNames.has(r))
+ const toRemove = this.additive
+ ? [] // Additive mode: never remove
+ : [...liveRepoNames].filter(r => !desiredNames.has(r))
+
+ if (toAdd.length === 0 && toRemove.length === 0) {
+ this.log.debug(`App '${appSlug}': no changes needed`)
+ return results
+ }
+
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, {
+ msg: `App '${appSlug}' installation repos`,
+ additions: toAdd.length > 0 ? toAdd : null,
+ modifications: null,
+ deletions: toRemove.length > 0 ? toRemove : null
+ }))
+ return results
+ }
+
+ // Process removals first, then additions, so a repo that should be both
+ // removed (by one config) and added (by another) ends up present.
+ if (toRemove.length > 0) {
+ await this.enterpriseClient.removeReposFromInstallation(this.org, installation_id, toRemove)
+ this.log.debug(`App '${appSlug}': removed ${toRemove.length} repos`)
+ }
+ if (toAdd.length > 0) {
+ await this.enterpriseClient.addReposToInstallation(this.org, installation_id, toAdd)
+ this.log.debug(`App '${appSlug}': added ${toAdd.length} repos`)
+ }
+
+ return results
+ }
+
/**
* Process a single app's delta change.
* @private
@@ -206,7 +253,7 @@ class AppInstallations {
return results
}
- // Handle "all" selection — set repository_selection to all via the API
+ // Handle "all" selection — toggle the installation to 'all' via the API
if (repository_selection === 'all') {
if (this.nop) {
results.push(new NopCommand(
@@ -223,18 +270,8 @@ class AppInstallations {
return results
}
- // For "all" repos, get the full list and add all
- const allRepoNames = await this._getAllRepoNames()
- const liveRepos = await this.enterpriseClient.listInstallationRepos(installation_id)
- const liveRepoNames = new Set(liveRepos.map(r => r.name))
- const toAdd = [...allRepoNames].filter(r => !liveRepoNames.has(r))
-
- if (toAdd.length > 0) {
- const addIds = await this._resolveRepoIds(toAdd)
- await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
- this.log.debug(`App '${app_slug}': added all repos (${addIds.length} new)`)
- }
-
+ await this.enterpriseClient.setRepositorySelection(this.org, installation_id, 'all')
+ this.log.debug(`App '${app_slug}': set repository_selection to 'all'`)
return results
}
@@ -257,48 +294,17 @@ class AppInstallations {
}
if (hasUnselections) {
- const removeIds = await this._resolveRepoIds([...repository_unselection])
- await this.enterpriseClient.removeReposFromInstallation(installation_id, removeIds)
- this.log.debug(`App '${app_slug}': removed ${removeIds.length} repos`)
+ await this.enterpriseClient.removeReposFromInstallation(this.org, installation_id, [...repository_unselection])
+ this.log.debug(`App '${app_slug}': removed ${repository_unselection.size} repos`)
}
if (hasSelections) {
- const addIds = await this._resolveRepoIds([...repository_selection])
- await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
- this.log.debug(`App '${app_slug}': added ${addIds.length} repos`)
+ await this.enterpriseClient.addReposToInstallation(this.org, installation_id, [...repository_selection])
+ this.log.debug(`App '${app_slug}': added ${repository_selection.size} repos`)
}
return results
}
-
- /**
- * Get all repo names visible to the installation.
- * @private
- */
- async _getAllRepoNames () {
- const repos = await this.github.paginate('GET /installation/repositories')
- return new Set(repos.map(r => r.name))
- }
-
- /**
- * Resolve repo names to IDs.
- * @private
- */
- async _resolveRepoIds (repoNames) {
- const ids = []
- for (const name of repoNames) {
- try {
- const { data } = await this.github.repos.get({
- owner: this.repo.owner,
- repo: name
- })
- ids.push(data.id)
- } catch (e) {
- this.log.debug(`Could not resolve repo ID for '${name}': ${e.message}`)
- }
- }
- return ids
- }
}
module.exports = AppInstallations
diff --git a/lib/settings.js b/lib/settings.js
index 590071610..1e3fb80df 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1708,8 +1708,10 @@ class Settings {
}
const installationMap = new Map()
+ const selectionMap = new Map()
for (const inst of orgInstallations) {
installationMap.set(inst.app_slug, inst.id)
+ selectionMap.set(inst.app_slug, inst.repository_selection)
}
// Process org-level config
@@ -1785,6 +1787,12 @@ class Settings {
}
}
+ // Attach each app's current (live) repository_selection so the plugin can
+ // decide whether to toggle 'all' ↔ 'selected' or add/remove individually.
+ for (const [slug, entry] of Object.entries(desiredState)) {
+ entry.current_selection = selectionMap.get(slug)
+ }
+
return desiredState
}
diff --git a/test/unit/lib/appOctokitClient.test.js b/test/unit/lib/appOctokitClient.test.js
index fdd375177..dd7206f64 100644
--- a/test/unit/lib/appOctokitClient.test.js
+++ b/test/unit/lib/appOctokitClient.test.js
@@ -27,17 +27,19 @@ describe('AppOctokitClient', () => {
})
describe('listOrgInstallations', () => {
- it('returns installations filtered by org', async () => {
+ it('returns org installations', async () => {
github.paginate.mockResolvedValue([
- { id: 1, app_slug: 'app-a', account: { login: 'my-org' } },
- { id: 2, app_slug: 'app-b', account: { login: 'other-org' } },
- { id: 3, app_slug: 'app-c', account: { login: 'my-org' } }
+ { id: 1, app_slug: 'app-a', repository_selection: 'all' },
+ { id: 3, app_slug: 'app-c', repository_selection: 'selected' }
])
const result = await client.listOrgInstallations('my-org')
expect(result).toHaveLength(2)
expect(result[0].app_slug).toBe('app-a')
- expect(result[1].app_slug).toBe('app-c')
+ expect(github.request.endpoint.merge).toHaveBeenCalledWith(
+ 'GET /enterprises/{enterprise}/apps/organizations/{org}/installations',
+ expect.objectContaining({ enterprise: 'my-enterprise', org: 'my-org' })
+ )
})
it('throws descriptive error on 403', async () => {
@@ -55,41 +57,77 @@ describe('AppOctokitClient', () => {
})
})
+ describe('setRepositorySelection', () => {
+ it("toggles to 'all' without repositories", async () => {
+ await client.setRepositorySelection('my-org', 123, 'all')
+ expect(github.request).toHaveBeenCalledWith(
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories',
+ expect.objectContaining({
+ org: 'my-org',
+ installation_id: 123,
+ repository_selection: 'all'
+ })
+ )
+ const callArgs = github.request.mock.calls[0][1]
+ expect(callArgs.repositories).toBeUndefined()
+ })
+
+ it("toggles to 'selected' with repository names", async () => {
+ await client.setRepositorySelection('my-org', 123, 'selected', ['repo-a', 'repo-b'])
+ expect(github.request).toHaveBeenCalledWith(
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories',
+ expect.objectContaining({
+ repository_selection: 'selected',
+ repositories: ['repo-a', 'repo-b']
+ })
+ )
+ })
+ })
+
describe('addReposToInstallation', () => {
it('does nothing for empty array', async () => {
- await client.addReposToInstallation(123, [])
+ await client.addReposToInstallation('my-org', 123, [])
expect(github.request).not.toHaveBeenCalled()
})
- it('sends single batch for <= 50 repos', async () => {
- const ids = Array.from({ length: 10 }, (_, i) => i + 1)
- await client.addReposToInstallation(123, ids)
+ it('sends single batch for <= 50 repos using names', async () => {
+ const names = Array.from({ length: 10 }, (_, i) => `repo-${i}`)
+ await client.addReposToInstallation('my-org', 123, names)
expect(github.request).toHaveBeenCalledTimes(1)
expect(github.request).toHaveBeenCalledWith(
- expect.stringContaining('POST'),
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories/add',
expect.objectContaining({
- repository_ids: ids,
- installation_id: 123
+ repositories: names,
+ installation_id: 123,
+ org: 'my-org'
})
)
})
it('batches into chunks of 50', async () => {
- const ids = Array.from({ length: 120 }, (_, i) => i + 1)
- await client.addReposToInstallation(123, ids)
+ const names = Array.from({ length: 120 }, (_, i) => `repo-${i}`)
+ await client.addReposToInstallation('my-org', 123, names)
expect(github.request).toHaveBeenCalledTimes(3) // 50 + 50 + 20
})
})
describe('removeReposFromInstallation', () => {
it('does nothing for empty array', async () => {
- await client.removeReposFromInstallation(123, [])
+ await client.removeReposFromInstallation('my-org', 123, [])
expect(github.request).not.toHaveBeenCalled()
})
+ it('uses the remove endpoint with names', async () => {
+ await client.removeReposFromInstallation('my-org', 123, ['repo-a'])
+ expect(github.request).toHaveBeenCalledWith(
+ 'PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories/remove',
+ expect.objectContaining({ repositories: ['repo-a'] })
+ )
+ })
+
it('batches into chunks of 50', async () => {
- const ids = Array.from({ length: 75 }, (_, i) => i + 1)
- await client.removeReposFromInstallation(123, ids)
+ const names = Array.from({ length: 75 }, (_, i) => `repo-${i}`)
+ await client.removeReposFromInstallation('my-org', 123, names)
expect(github.request).toHaveBeenCalledTimes(2) // 50 + 25
})
})
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
index 6e7333f1b..a69c23eb8 100644
--- a/test/unit/lib/plugins/appInstallations.test.js
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -108,17 +108,10 @@ describe('AppInstallations', () => {
})
it('processes unselections before selections in non-nop mode', async () => {
- // repo-a (add) resolves to id 100; repo-b (remove) resolves to id 200
- github.repos.get.mockImplementation(({ repo }) => {
- if (repo === 'repo-a') return Promise.resolve({ data: { id: 100 } })
- if (repo === 'repo-b') return Promise.resolve({ data: { id: 200 } })
- return Promise.resolve({ data: { id: 0 } })
- })
-
const callOrder = []
appGithub.request.mockImplementation((route) => {
- if (route.startsWith('DELETE')) callOrder.push('remove')
- if (route.startsWith('POST')) callOrder.push('add')
+ if (route.includes('/repositories/remove')) callOrder.push('remove')
+ if (route.includes('/repositories/add')) callOrder.push('add')
return Promise.resolve({ data: {} })
})
@@ -208,5 +201,50 @@ describe('AppInstallations', () => {
expect(result).toEqual([])
})
+
+ it("toggles to 'all' when desired is all and current is selected", async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ await plugin.syncFull({
+ copilot: { installation_id: 1, repos: 'all', current_selection: 'selected' }
+ })
+
+ expect(appGithub.request).toHaveBeenCalledWith(
+ expect.stringContaining('/repositories'),
+ expect.objectContaining({ repository_selection: 'all', installation_id: 1 })
+ )
+ })
+
+ it('skips when desired is all and current is already all', async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: 'all', current_selection: 'all' }
+ })
+
+ expect(result).toEqual([])
+ expect(appGithub.request).not.toHaveBeenCalled()
+ })
+
+ it("narrows from 'all' to 'selected' when desired is a set", async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(['repo-a']), current_selection: 'all' }
+ })
+
+ expect(appGithub.request).toHaveBeenCalledWith(
+ expect.stringContaining('/repositories'),
+ expect.objectContaining({ repository_selection: 'selected', repositories: ['repo-a'] })
+ )
+ })
+
+ it("leaves 'all' untouched in additive mode", async () => {
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ plugin.additive = true
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(['repo-a']), current_selection: 'all' }
+ })
+
+ expect(result).toEqual([])
+ expect(appGithub.request).not.toHaveBeenCalled()
+ })
})
})
From c55645eb62ab3ff9b7a71e90ea0ac69b6ee5a334 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Mon, 6 Jul 2026 07:50:58 -0400
Subject: [PATCH 42/67] Add verification for app-installations plugin
functionality
---
index.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/index.js b/index.js
index 3b512282a..97b8c812e 100644
--- a/index.js
+++ b/index.js
@@ -6,6 +6,7 @@ const Glob = require('./lib/glob')
const ConfigManager = require('./lib/configManager')
const NopCommand = require('./lib/nopcommand')
const SettingsGenerator = require('./lib/settingsGenerator')
+const AppOctokitClient = require('./lib/appOctokitClient')
const env = require('./lib/env')
let deploymentConfig
@@ -288,6 +289,61 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
appSlug = app.data.slug
robot.log.debug(`Validated the app is configured properly = \n${JSON.stringify(app.data, null, 2)}`)
}
+
+ await verifyAppInstallationsPlugin(installations)
+ }
+
+ /**
+ * Verifies that the app-installations plugin can function properly.
+ *
+ * When the `GH_ENTERPRISE` env variable is set, this:
+ * 1. Finds the enterprise installation matching the slug.
+ * 2. Mints an installation token for that enterprise installation.
+ * 3. Confirms the token has permission to manage app installations in the
+ * target org (`GH_ORG`) by listing the org's app installations via the
+ * Enterprise organization installations API.
+ *
+ * If `GH_ENTERPRISE` is not set, this verification is skipped entirely.
+ *
+ * @param {Array} installations - The app's installations (JWT-listed)
+ */
+ async function verifyAppInstallationsPlugin (installations) {
+ const enterpriseSlug = process.env.GH_ENTERPRISE
+ if (!enterpriseSlug) {
+ robot.log.debug('GH_ENTERPRISE is not set — skipping app-installations plugin verification')
+ return
+ }
+
+ const org = process.env.GH_ORG
+ if (!org) {
+ robot.log.warn('GH_ENTERPRISE is set but GH_ORG is not — cannot verify app-installations plugin without a target org')
+ return
+ }
+
+ // Find the installation targeting this enterprise
+ const enterpriseInstallation = (installations || []).find(
+ i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterpriseSlug
+ )
+ if (!enterpriseInstallation) {
+ robot.log.warn(`No enterprise installation found for slug '${enterpriseSlug}'. App-installations plugin will not be able to manage app access. Ensure safe-settings is installed on the enterprise.`)
+ return
+ }
+
+ try {
+ // Mint an installation token for the enterprise installation
+ const enterpriseGithub = await robot.auth(enterpriseInstallation.id)
+ const client = new AppOctokitClient({
+ github: enterpriseGithub,
+ enterpriseSlug,
+ log: robot.log
+ })
+
+ // Confirm the token can list org app installations (validates permission)
+ const orgInstallations = await client.listOrgInstallations(org)
+ robot.log.info(`App-installations plugin verified: enterprise '${enterpriseSlug}' installation (id: ${enterpriseInstallation.id}) can manage apps in org '${org}' (${orgInstallations.length} installation(s) visible)`)
+ } catch (e) {
+ robot.log.error(`App-installations plugin verification failed for enterprise '${enterpriseSlug}' / org '${org}': ${e.message}`)
+ }
}
async function syncInstallation (nop = false) {
From c43625a4d43a3bc40f43fbd9243dfc5fecf26b44 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:40:18 -0400
Subject: [PATCH 43/67] Refactor enterprise installation handling: add
getEnterpriseAppClient function and simplify context enrichment
---
index.js | 86 +++++++++++++++++++++++++++++++++-----------------------
1 file changed, 51 insertions(+), 35 deletions(-)
diff --git a/index.js b/index.js
index 97b8c812e..b44db0430 100644
--- a/index.js
+++ b/index.js
@@ -150,6 +150,44 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
}
+ /**
+ * Finds the enterprise installation for a given slug and returns an Octokit
+ * client authenticated with the enterprise installation token, along with
+ * the installation ID.
+ *
+ * Uses the cached enterprise installation ID when available to avoid
+ * re-listing installations. Returns null if no matching enterprise
+ * installation is found.
+ *
+ * @param {string} enterpriseSlug - Enterprise slug
+ * @returns {Promise<{ appGithub: object, installationId: number } | null>}
+ */
+ async function getEnterpriseAppClient (enterpriseSlug) {
+ if (!enterpriseSlug) return null
+
+ // Use cached enterprise installation ID if available
+ if (cachedEnterpriseInstallationId) {
+ const appGithub = await robot.auth(cachedEnterpriseInstallationId)
+ return { appGithub, installationId: cachedEnterpriseInstallationId }
+ }
+
+ // 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 === enterpriseSlug
+ )
+ if (!enterpriseInstallation) {
+ return null
+ }
+ cachedEnterpriseInstallationId = enterpriseInstallation.id
+ const enterpriseGithub = await robot.auth(enterpriseInstallation.id)
+ return { appGithub: enterpriseGithub, installationId: enterpriseInstallation.id }
+ }
+
/**
* Enriches the context with enterprise info for app installation management.
* Extracts enterprise slug from the webhook payload, finds the enterprise
@@ -164,24 +202,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
if (enterprise && enterprise.slug) {
context.enterpriseSlug = enterprise.slug
try {
- // Use cached enterprise installation ID if available
- if (cachedEnterpriseInstallationId) {
- context.appGithub = await robot.auth(cachedEnterpriseInstallationId)
- return
- }
-
- // 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) {
- cachedEnterpriseInstallationId = enterpriseInstallation.id
- context.appGithub = await robot.auth(cachedEnterpriseInstallationId)
+ const result = await getEnterpriseAppClient(enterprise.slug)
+ if (result) {
+ context.appGithub = result.appGithub
} else {
robot.log.debug(`No enterprise installation found for slug '${enterprise.slug}'. App installation management will not be available.`)
}
@@ -290,7 +313,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
robot.log.debug(`Validated the app is configured properly = \n${JSON.stringify(app.data, null, 2)}`)
}
- await verifyAppInstallationsPlugin(installations)
+ await verifyAppInstallationsPlugin()
}
/**
@@ -304,10 +327,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
* Enterprise organization installations API.
*
* If `GH_ENTERPRISE` is not set, this verification is skipped entirely.
- *
- * @param {Array} installations - The app's installations (JWT-listed)
*/
- async function verifyAppInstallationsPlugin (installations) {
+ async function verifyAppInstallationsPlugin () {
const enterpriseSlug = process.env.GH_ENTERPRISE
if (!enterpriseSlug) {
robot.log.debug('GH_ENTERPRISE is not set — skipping app-installations plugin verification')
@@ -320,27 +341,22 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return
}
- // Find the installation targeting this enterprise
- const enterpriseInstallation = (installations || []).find(
- i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterpriseSlug
- )
- if (!enterpriseInstallation) {
- robot.log.warn(`No enterprise installation found for slug '${enterpriseSlug}'. App-installations plugin will not be able to manage app access. Ensure safe-settings is installed on the enterprise.`)
- return
- }
-
try {
- // Mint an installation token for the enterprise installation
- const enterpriseGithub = await robot.auth(enterpriseInstallation.id)
+ const result = await getEnterpriseAppClient(enterpriseSlug)
+ if (!result) {
+ robot.log.warn(`No enterprise installation found for slug '${enterpriseSlug}'. App-installations plugin will not be able to manage app access. Ensure safe-settings is installed on the enterprise.`)
+ return
+ }
+
const client = new AppOctokitClient({
- github: enterpriseGithub,
+ github: result.appGithub,
enterpriseSlug,
log: robot.log
})
// Confirm the token can list org app installations (validates permission)
const orgInstallations = await client.listOrgInstallations(org)
- robot.log.info(`App-installations plugin verified: enterprise '${enterpriseSlug}' installation (id: ${enterpriseInstallation.id}) can manage apps in org '${org}' (${orgInstallations.length} installation(s) visible)`)
+ robot.log.info(`App-installations plugin verified: enterprise '${enterpriseSlug}' installation (id: ${result.installationId}) can manage apps in org '${org}' (${orgInstallations.length} installation(s) visible)`)
} catch (e) {
robot.log.error(`App-installations plugin verification failed for enterprise '${enterpriseSlug}' / org '${org}': ${e.message}`)
}
From 1364b099d8ccc64a3d04d02b15405a2045a2f821 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Mon, 6 Jul 2026 18:15:52 -0400
Subject: [PATCH 44/67] Add ADR 0001: app installation management plugin
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>
---
docs/adr/0001-app-installation-plugin.md | 190 +++++++++++++++++++++++
docs/adr/README.md | 10 ++
2 files changed, 200 insertions(+)
create mode 100644 docs/adr/0001-app-installation-plugin.md
create mode 100644 docs/adr/README.md
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
new file mode 100644
index 000000000..e8ab7d1ca
--- /dev/null
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -0,0 +1,190 @@
+# 1. App installation management plugin
+
+- Status: Accepted
+- Date: 2026-07-06
+- Deciders: safe-settings maintainers
+- Related PR: `decyjphr-app-installation-plugin`
+
+## Context
+
+Safe-settings manages configuration whose **target is a repository** (branch
+protection, labels, collaborators, …), with a couple of exceptions
+(`rulesets`, `custom_repository_roles`) that target the organization. All of
+these are driven through the org → suborg → repo configuration hierarchy and
+applied by `syncAll` / `syncSelectedRepos` / `sync`.
+
+We need a new capability where the **target of the operation is a GitHub App
+installation** rather than a repository. Concretely, safe-settings should
+declaratively control **which repositories each installed GitHub App can
+access** (`repository_selection`), driven by the same config hierarchy:
+
+- **Org-level `settings.yml`** → the app should have access to **all** repos in
+ the org.
+- **Suborg-level `suborgs/*.yml`** → repos selected by the suborg's targeting
+ criteria (custom properties, teams, names).
+- **Repo-level `repos/*.yml`** → the specific repo, by name.
+
+Two hard constraints shaped the design:
+
+1. **A different credential is required.** Reading org/suborg/repo config and
+ resolving repos can use the normal per-installation Octokit client. But
+ **mutating an app's installation repository access** requires an Octokit
+ client authenticated as the App at the **enterprise** level, using the
+ [Enterprise Organization Installations API][ent-api] (permission:
+ *Enterprise organization installations*).
+2. **Drift.** Humans can change an app's repo access outside safe-settings, so
+ we want to detect and revert that drift.
+
+We also want the design to accommodate **future non-repo targets** (e.g.,
+Copilot policies) without another ground-up rewrite.
+
+## Decision
+
+Add an `app_installations` plugin plus supporting infrastructure, wired into
+the existing sync pipeline as a **separate phase**.
+
+### Configuration shape
+
+```yaml
+# settings.yml (org level) — implies "all repos"
+app_installations:
+ - app_slug: copilot
+ - app_slug: dependabot
+
+# suborgs/team-a.yml — repos selected by this suborg's criteria
+app_installations:
+ - app_slug: copilot
+
+# repos/my-repo.yml — this specific repo
+app_installations:
+ - app_slug: copilot
+```
+
+### Components
+
+| Component | Responsibility |
+| --- | --- |
+| `lib/plugins/appInstallations.js` | Reconcile desired vs. live repo access per app (`syncDelta` / `syncFull`). Not `Diffable` — app installations are an org-scoped target, not a per-repo list. |
+| `lib/appOctokitClient.js` | Enterprise-level App client for the Enterprise Organization Installations API. |
+| `lib/repoSelector.js` | Resolve a repo set from **fixed** criteria: name, team, custom properties, or "all". |
+| `lib/settings.js` | `syncAppInstallations` phase; delta computation from changed configs; full desired-state computation. |
+| `index.js` | Enterprise-client enrichment on the context; `installation_target` webhook handler. |
+
+### Sync model: delta vs. full
+
+- **Delta (`syncSelectedRepos`, push events)**: only apps that appear in
+ **changed** config files are "marked for change". For each changed
+ suborg/repo file we compute, per app:
+ - `repository_selection` — repos to **add**,
+ - `repository_unselection` — repos to **remove** (by diffing the previous
+ `baseRef` version of *that one file* — one extra fetch, reusing the existing
+ "removed from suborg targeting" pattern).
+
+ Apps configured with org-level "all" are **not** handled in delta mode; they
+ are managed only by full sync.
+
+- **Full (`syncAll`, cron/manual)**: recompute the complete desired state for
+ every managed app across all config layers and reconcile against live API
+ state. This is the only place the expensive full computation runs, and the
+ only path that reconciles drift.
+
+### Enterprise API usage
+
+All mutations go through the org-scoped Enterprise Organization Installations
+API (version `2026-03-10`) and operate on repository **names**:
+
+- List installations: `GET /enterprises/{ent}/apps/organizations/{org}/installations`
+- List repos: `GET …/installations/{id}/repositories`
+- Toggle all/selected: `PATCH …/installations/{id}/repositories`
+- Add: `PATCH …/installations/{id}/repositories/add`
+- Remove: `PATCH …/installations/{id}/repositories/remove`
+
+Add/remove are capped at **50 repos per call** and are auto-batched.
+
+## Decisions and rationale
+
+1. **Enterprise auth is a prerequisite, not a config knob.**
+ safe-settings must already be installed on the enterprise with the
+ *Enterprise organization installations* permission. If it is not, the plugin
+ surfaces a clear error rather than accepting a separate private-key env var.
+ The enterprise slug is read from the webhook payload
+ (`payload.enterprise.slug`); the enterprise installation id is discovered via
+ `apps.listInstallations` (matching `target_type === 'Enterprise'` &&
+ `account.slug === enterprise.slug`) and cached for reuse.
+
+2. **App installation sync is a separate phase**, not folded into `updateOrg()`.
+ This keeps repo iteration and app reconciliation independent and easier to
+ reason about and disable.
+
+3. **Fixed repo-selection criteria only** (name, team, custom properties, plus
+ "all"). No arbitrary Search API queries, to keep behavior predictable and
+ reuse existing `getReposForTeam` / `getRepositoriesByProperty` patterns.
+
+4. **Org-level "all" takes precedence** over any suborg/repo-level selection or
+ exclusion. If an app is "all" at org level, the installation is toggled to
+ `all` and deltas for that app are skipped.
+
+5. **Repository NAMES, not IDs.** The Enterprise Org Installations API accepts
+ names, so the plugin no longer resolves names → IDs or enumerates all repos
+ for the "all" case (it uses the native toggle instead).
+
+6. **Unselection before selection.** In both delta and full sync, removals are
+ applied before additions, so a repo removed by one config layer and added by
+ another ends up **present** (net-correct even with transient churn).
+
+7. **Churn skip.** In delta mode, if an app's targeting is unchanged between the
+ previous and current versions of a file, it is skipped entirely to avoid
+ redundant add/remove writes.
+
+8. **Full-sync `current_selection` awareness.** Full sync reads each
+ installation's live `repository_selection` and chooses the minimal action:
+ skip when already correct; toggle `all` ↔ `selected`; or diff names and
+ remove-then-add when already `selected`. In `additive` mode it never narrows
+ an `all` installation.
+
+9. **`disable_plugins` / `additive_plugins` support.** `app_installations`
+ participates in the same gating: it can be disabled at any layer, and in
+ additive mode it only adds, never removes.
+
+10. **Future target abstraction.** The plugin is structured around a target that
+ is *not* a repository, paving the way for future targets (e.g., Copilot
+ policies) to reuse the same phase/plumbing without being repo-bound.
+
+## Consequences
+
+### Positive
+
+- App access is now declarative and flows through the existing config hierarchy.
+- Delta processing keeps incremental (push-triggered) runs cheap.
+- Names-based API + native "all" toggle removes an entire class of ID-resolution
+ and enumeration work.
+- Batching respects the 50-repo API limit transparently.
+
+### Negative / limitations
+
+- **Managed-app drift relies on the scheduled full sync.** An app only receives
+ `installation` repository events for its *own* installation, so there is no
+ webhook that reports drift on *other* managed apps. The
+ `installation.repositories_added/removed` handler was intentionally **removed**
+ because it could not detect managed-app drift; only `installation_target` is
+ retained. Drift on managed apps is reconciled on the next cron full sync.
+- **Multi-suborg overlap can briefly churn** in delta mode (a repo may be
+ removed then re-added within a run). The unselection-before-selection ordering
+ guarantees the net end state is correct.
+- Requires an enterprise-level installation with the specific permission; orgs
+ not on enterprise cannot use the plugin.
+
+## Alternatives considered
+
+- **Enumerate all repos and add them individually for the "all" case** —
+ rejected in favor of the API's native `repository_selection: all` toggle
+ (fewer calls, no drift from newly created repos).
+- **Arbitrary Search API queries for repo selection** — rejected for now in
+ favor of a fixed, predictable criteria set.
+- **Suborg exclusions overriding org "all"** — rejected; org "all" takes
+ precedence to keep the mental model simple.
+- **A dedicated private-key env var for enterprise auth** — rejected in favor of
+ reusing the existing app credentials and treating enterprise installation as a
+ prerequisite.
+
+[ent-api]: https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/organization-installations?apiVersion=2026-03-10
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 000000000..03a952f8c
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,10 @@
+# Architecture Decision Records
+
+This directory captures significant architectural decisions for safe-settings
+using lightweight [ADRs](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions).
+
+Each record is numbered and immutable once accepted; supersede rather than edit.
+
+| ADR | Title | Status |
+| --- | --- | --- |
+| [0001](0001-app-installation-plugin.md) | App installation management plugin | Accepted |
From 99cd47ef46eb5b3452c8035c0713b175aa97db69 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Mon, 6 Jul 2026 18:20:00 -0400
Subject: [PATCH 45/67] Enhance app installations plugin: add subject handling
and improve repo selection logic
---
lib/nopcommand.js | 16 +-
lib/plugins/appInstallations.js | 12 +-
lib/settings.js | 72 +++++-
smoke-test.js | 415 +++++++++++++++++++++++++++++++-
4 files changed, 498 insertions(+), 17 deletions(-)
diff --git a/lib/nopcommand.js b/lib/nopcommand.js
index 75965a8ba..133eae48d 100644
--- a/lib/nopcommand.js
+++ b/lib/nopcommand.js
@@ -1,8 +1,22 @@
class NopCommand {
- constructor (pluginName, repo, endpoint, action, type = 'INFO') {
+ constructor (pluginName, repo, endpoint, action, type = 'INFO', subject = null) {
+ // Ergonomic overload: allow passing the subject in the `type` position so
+ // callers can supply a subject while omitting the (default 'INFO') type,
+ // e.g. new NopCommand(plugin, repo, endpoint, action, { name, type }).
+ if (type !== null && typeof type === 'object') {
+ subject = type
+ type = 'INFO'
+ }
this.type = type
this.plugin = pluginName
this.repo = repo.repo
+ // Optional presentation subject. Some plugins (e.g. app_installations)
+ // operate on a non-repo entity — the "subject" of the change is a GitHub
+ // App, not a repository. `subject` overrides the repo as the heading in the
+ // PR-comment/check-run report, while `repo` is still used for grouping and
+ // repo counts. Defaults to the repo so existing plugins are unaffected.
+ this.subject = (subject && subject.name) || repo.repo
+ this.subjectType = (subject && subject.type) || 'repo'
this.endpoint = endpoint ? endpoint.url : ''
this.body = endpoint ? endpoint.body : ''
// check if action is a string
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index 4e008a863..aede50331 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -152,7 +152,7 @@ class AppInstallations {
additions: ['(all repositories)'],
modifications: null,
deletions: null
- }))
+ }, { name: appSlug, type: 'app' }))
return results
}
await this.enterpriseClient.setRepositorySelection(this.org, installation_id, 'all')
@@ -181,7 +181,7 @@ class AppInstallations {
additions: [...desiredNames],
modifications: null,
deletions: ['(all repositories)']
- }))
+ }, { name: appSlug, type: 'app' }))
return results
}
await this.enterpriseClient.setRepositorySelection(this.org, installation_id, 'selected', [...desiredNames])
@@ -209,7 +209,7 @@ class AppInstallations {
additions: toAdd.length > 0 ? toAdd : null,
modifications: null,
deletions: toRemove.length > 0 ? toRemove : null
- }))
+ }, { name: appSlug, type: 'app' }))
return results
}
@@ -265,7 +265,8 @@ class AppInstallations {
additions: ['(all repositories)'],
modifications: null,
deletions: null
- }
+ },
+ { name: app_slug, type: 'app' }
))
return results
}
@@ -288,7 +289,8 @@ class AppInstallations {
additions,
modifications: null,
deletions
- }
+ },
+ { name: app_slug, type: 'app' }
))
return results
}
diff --git a/lib/settings.js b/lib/settings.js
index 1e3fb80df..2f41e5e97 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -170,14 +170,19 @@ function filterActionByChangedNames (action, changedNames) {
function buildChangeSections (changes, baseConfig, config) {
return Object.keys(changes).map(plugin => {
+ const isAppInstallations = plugin === 'app_installations'
const repoSections = []
Object.keys(changes[plugin]).forEach(repo => {
const targetMap = new Map()
changes[plugin][repo].forEach(action => {
- targetsForAction(plugin, repo, action, baseConfig, config).forEach(target => {
+ const actionTargets = isAppInstallations
+ ? appInstallationTargets(action)
+ : targetsForAction(plugin, repo, action, baseConfig, config)
+ actionTargets.forEach(target => {
if (!targetMap.has(target.target)) {
targetMap.set(target.target, {
target: target.target,
+ flat: target.flat === true,
rows: []
})
}
@@ -194,11 +199,17 @@ function buildChangeSections (changes, baseConfig, config) {
const changeCount = filteredRepoSections.reduce((count, repoSection) => {
return count + repoSection.targets.reduce((targetCount, target) => targetCount + target.rows.length, 0)
}, 0)
- const targetCount = filteredRepoSections.reduce((count, repoSection) => count + repoSection.targets.length, 0)
+ // For flat targets (app_installations) each row is a distinct change; for
+ // regular targets each target counts as one changed setting.
+ const targetCount = filteredRepoSections.reduce((count, repoSection) => {
+ return count + repoSection.targets.reduce((tc, target) => tc + (target.flat ? target.rows.length : 1), 0)
+ }, 0)
const repoCount = filteredRepoSections.length
+ const subjectSingular = isAppInstallations ? 'app' : 'repo'
+ const subjectPlural = isAppInstallations ? 'apps' : 'repos'
const targetSingular = plugin.toLowerCase() === 'rulesets' ? 'policy' : 'setting'
const targetPlural = plugin.toLowerCase() === 'rulesets' ? 'policies' : 'settings'
- const impactSummary = `${repoCount} ${pluralize(repoCount, 'repo', 'repos')}, ${targetCount} ${pluralize(targetCount, targetSingular, targetPlural)} changed`
+ const impactSummary = `${repoCount} ${pluralize(repoCount, subjectSingular, subjectPlural)}, ${targetCount} ${pluralize(targetCount, targetSingular, targetPlural)} changed`
return {
plugin,
repoSections: filteredRepoSections,
@@ -211,10 +222,40 @@ function buildChangeSections (changes, baseConfig, config) {
}).filter(section => section.repoSections.length > 0)
}
+// app_installations changes are presented with the GitHub App as the subject
+// (heading) and a flat list of repositories added/removed (or a toggle to
+// "all"). Returns a single flat target whose rows carry a `label` per change.
+function appInstallationTargets (action) {
+ if (!action || typeof action === 'string') {
+ return [{ target: '', flat: true, rows: action ? [{ change: 'Info', label: action }] : [] }]
+ }
+ const toList = value => {
+ if (value === null || value === undefined) return []
+ const arr = Array.isArray(value) ? value : [value]
+ return arr
+ .filter(entry => !isDeepEmpty(entry))
+ .map(entry => (typeof entry === 'string' ? entry : (getEntryIdentityValue(entry) || JSON.stringify(entry))))
+ }
+ const rows = []
+ toList(action.additions).forEach(label => rows.push({ change: 'Added', label }))
+ toList(action.modifications).forEach(label => rows.push({ change: 'Modified', label }))
+ toList(action.deletions).forEach(label => rows.push({ change: 'Deleted', label }))
+ if (rows.length === 0 && action.msg) rows.push({ change: 'Info', label: action.msg })
+ return [{ target: '', flat: true, rows }]
+}
+
function renderChangeSections (changeSections) {
return changeSections.map(section => {
const repoBlocks = section.repoSections.map(repoSection => {
const targetBlocks = repoSection.targets.map(target => {
+ if (target.flat) {
+ return target.rows.map(row => {
+ const marker = changeMarker(row.change)
+ return row.change === 'Info'
+ ? `- ${marker} ${markdownText(row.label)}`
+ : `- ${marker} ${markdownInlineCode(row.label)}`
+ }).join('\n')
+ }
return `- ${markdownInlineCode(target.target)}\n${renderFieldChangeList(target.rows, ' ')}`
})
return `**${markdownText(displayRepoName(repoSection.repo))}**\n${targetBlocks.join('\n')}`
@@ -225,9 +266,13 @@ function renderChangeSections (changeSections) {
}
function affectedRepoCount (changeSections) {
- return new Set(changeSections.flatMap(section => {
- return section.repoSections.map(repoSection => displayRepoName(repoSection.repo))
- })).size
+ return new Set(changeSections
+ // app_installations sections are keyed by app subject, not repositories,
+ // so they must not inflate the "repos affected" count.
+ .filter(section => section.plugin !== 'app_installations')
+ .flatMap(section => {
+ return section.repoSections.map(repoSection => displayRepoName(repoSection.repo))
+ })).size
}
function displayRepoName (repo) {
@@ -1103,10 +1148,14 @@ class Settings {
if (!stats.changes[res.plugin]) {
stats.changes[res.plugin] = {}
}
- if (!stats.changes[res.plugin][res.repo]) {
- stats.changes[res.plugin][res.repo] = []
+ // Group by the result's subject (defaults to the repo). Plugins that
+ // act on a non-repo entity — e.g. app_installations, whose subject is
+ // a GitHub App — group under that subject instead of the (org) repo.
+ const subject = res.subject || res.repo
+ if (!stats.changes[res.plugin][subject]) {
+ stats.changes[res.plugin][subject] = []
}
- stats.changes[res.plugin][res.repo].push(res.action)
+ stats.changes[res.plugin][subject].push(res.action)
}
}
})
@@ -2185,6 +2234,11 @@ class Settings {
if (section !== 'repositories' && section !== 'repository') {
// Ignore any config that is not a plugin
if (section in Settings.PLUGINS) {
+ // app_installations is not a per-repo Diffable plugin; it operates at
+ // the org level on app installations and is reconciled separately by
+ // syncAppInstallations(). Skip it here so the per-repo pipeline does
+ // not try to call the (non-existent) sync() on it.
+ if (section === 'app_installations') continue
this.log.debug(`Found section ${section} in the config. Creating plugin...`)
const Plugin = Settings.PLUGINS[section]
// Include sectionName as 3rd element so callers can thread the
diff --git a/smoke-test.js b/smoke-test.js
index 40b705dc7..48739179a 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -84,6 +84,24 @@ const WEBHOOK_SETTLE_MS = 15000
// Fine-grained PAT for drift tests (must appear as a human, not Bot)
const GH_TOKEN = process.env.GH_TOKEN || ''
+// ─── App installation plugin config (Phase 17) ───────────────────────────────
+// Enterprise slug — required for the app_installations plugin, which manages
+// GitHub App installation repository access via the Enterprise organization
+// installations API. Read from GH_ENTERPRISE (same var safe-settings uses).
+const GH_ENTERPRISE = process.env.GH_ENTERPRISE || ''
+// One or more enterprise-level GitHub App slugs to exercise the plugin.
+// ONE app is sufficient for full lifecycle coverage; providing a second app
+// (comma-separated) additionally verifies that changes to one app's
+// installation do not affect another's. Pre-create these as enterprise GitHub
+// Apps and install them on the org (creation requires a human).
+// SMOKE_APP_SLUGS=app-one,app-two (or singular SMOKE_APP_SLUG=app-one)
+const APP_SLUGS = (process.env.SMOKE_APP_SLUGS || process.env.SMOKE_APP_SLUG || '')
+ .split(',').map(s => s.trim()).filter(Boolean)
+// Dedicated repos created/managed by the app_installations phase.
+const APP_TEST_REPOS = ['smoke-app-repo-1', 'smoke-app-repo-2', 'smoke-app-repo-3']
+// Enterprise org installations API version (matches lib/appOctokitClient.js).
+const APPS_API_VERSION = '2026-03-10'
+
// Interactive mode: pause after each phase for manual validation
// Accepts --interactive flag or bare positional "interactive" word.
const INTERACTIVE = process.argv.includes('--interactive') || process.argv.slice(2).includes('interactive')
@@ -127,6 +145,12 @@ class InteractiveExit extends Error {
// ─── Octokit client (initialized in main) ────────────────────────────────────
let octokit = null
+// Enterprise-installation-authenticated client (Phase 17). Null when the app
+// is not installed on the enterprise or GH_ENTERPRISE is unset.
+let entOctokit = null
+// Snapshot of each managed app's installation state, captured at the start of
+// Phase 17 and restored during teardown so shared apps are left untouched.
+const appInstallSnapshot = {}
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -399,6 +423,66 @@ async function waitForCheckRun (owner, repo, sha, { timeout = MAX_POLL_MS } = {}
}, { timeout, desc: 'check run to complete' })
}
+// Ensure a repository exists in the org (create it directly if missing).
+async function ensureRepo (name) {
+ try {
+ await octokit.rest.repos.get({ owner: ORG, repo: name })
+ return
+ } catch { /* needs creation */ }
+ try {
+ await octokit.rest.repos.createInOrg({ org: ORG, name, private: true, auto_init: true })
+ await poll(async () => {
+ try { await octokit.rest.repos.get({ owner: ORG, repo: name }); return true } catch { return null }
+ }, { desc: `repo ${name} to be created`, timeout: 30000 })
+ } catch (e) { log(` Could not create repo ${name}: ${e.message}`) }
+}
+
+// ─── Enterprise organization installations API helpers (Phase 17) ────────────
+
+async function listOrgAppInstallations () {
+ const options = entOctokit.request.endpoint.merge(
+ 'GET /enterprises/{enterprise}/apps/organizations/{org}/installations',
+ { enterprise: GH_ENTERPRISE, org: ORG, headers: { 'X-GitHub-Api-Version': APPS_API_VERSION } }
+ )
+ return entOctokit.paginate(options)
+}
+
+async function getAppInstallation (appSlug) {
+ const installations = await listOrgAppInstallations()
+ return installations.find(i => i.app_slug === appSlug) || null
+}
+
+async function listInstallationRepoNames (installationId) {
+ const options = entOctokit.request.endpoint.merge(
+ 'GET /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories',
+ { enterprise: GH_ENTERPRISE, org: ORG, installation_id: installationId, headers: { 'X-GitHub-Api-Version': APPS_API_VERSION } }
+ )
+ const repos = await entOctokit.paginate(options)
+ return repos.map(r => r.name)
+}
+
+async function setInstallationSelection (installationId, selection, repositories) {
+ const params = {
+ enterprise: GH_ENTERPRISE,
+ org: ORG,
+ installation_id: installationId,
+ repository_selection: selection,
+ headers: { 'X-GitHub-Api-Version': APPS_API_VERSION }
+ }
+ if (selection === 'selected') params.repositories = repositories || []
+ await entOctokit.request('PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories', params)
+}
+
+async function addInstallationRepos (installationId, repositoryNames) {
+ await entOctokit.request('PATCH /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id}/repositories/add', {
+ enterprise: GH_ENTERPRISE,
+ org: ORG,
+ installation_id: installationId,
+ repositories: repositoryNames,
+ headers: { 'X-GitHub-Api-Version': APPS_API_VERSION }
+ })
+}
+
// ─── Safe-settings process management ────────────────────────────────────────
let ssProcess = null
@@ -2366,6 +2450,24 @@ async function teardown () {
try { await octokit.rest.repos.update({ owner: ORG, repo: 'demo-repo-service1', archived: false }) } catch { /* ok */ }
for (const repo of TEST_REPOS) { await deleteRepo(ORG, repo) }
+ // Restore app installations to their original state so shared enterprise
+ // apps are left untouched by the smoke test.
+ if (entOctokit && Object.keys(appInstallSnapshot).length > 0) {
+ log('Restoring app installation repository selections...')
+ for (const [slug, snap] of Object.entries(appInstallSnapshot)) {
+ try {
+ if (snap.selection === 'all') {
+ await setInstallationSelection(snap.installationId, 'all')
+ } else if (snap.selection === 'selected' && snap.repos.length > 0) {
+ await setInstallationSelection(snap.installationId, 'selected', snap.repos)
+ }
+ } catch (e) { log(` Could not restore app '${slug}' installation: ${e.message}`) }
+ }
+ }
+
+ log('Deleting app-install smoke repos...')
+ for (const repo of APP_TEST_REPOS) { await deleteRepo(ORG, repo) }
+
log('Deleting test teams...')
for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) }
try { await deleteTeam(ORG, SMOKE_NR_TEAM) } catch { /* ok */ }
@@ -2701,6 +2803,297 @@ async function phase16RulesetNameResolution () {
}
}
+// ─── App installation config builders (Phase 17) ─────────────────────────────
+
+// Org-level settings.yml giving an app access to ALL repos.
+const settingsAppInstallAll = (slug) => `# App installations: org-level repository_selection all
+app_installations:
+ - app_slug: ${slug}
+ repository_selection: all
+`
+
+// Empty org settings (no app_installations) with an optional comment bump to
+// force a full sync while app selection is driven entirely by repo configs.
+const settingsAppInstallEmpty = (bump = '') => `# App installations: no org-level app config${bump ? ` (${bump})` : ''}
+`
+
+// Repo-level config that force-creates the repo and adds it to the app.
+const repoAppInstallConfig = (name, slug) => `repository:
+ name: ${name}
+app_installations:
+ - app_slug: ${slug}
+`
+
+// Suborg-level config that targets an explicit list of repos (suborgrepos) and
+// adds the app for those repos. Suborg config changes drive the delta sync.
+const suborgAppInstallConfig = (repos, slug) => `suborgrepos:
+${repos.map(r => ` - ${r}`).join('\n')}
+app_installations:
+ - app_slug: ${slug}
+`
+
+async function phase17AppInstallations () {
+ logPhase('Phase 17: App installation management (app_installations plugin)')
+
+ if (!GH_ENTERPRISE) {
+ log('17: GH_ENTERPRISE not set — skipping app_installations tests')
+ return
+ }
+ if (APP_SLUGS.length === 0) {
+ log('17: SMOKE_APP_SLUGS not set — skipping app_installations tests')
+ return
+ }
+ if (!entOctokit) {
+ logFail('17: enterprise installation not available (app not installed on enterprise or GH_ENTERPRISE mismatch) — cannot run app_installations tests')
+ return
+ }
+
+ const defaultBranch = await getDefaultBranch()
+ const app = APP_SLUGS[0]
+
+ // Snapshot every managed app's live installation state for restore in teardown.
+ for (const slug of APP_SLUGS) {
+ const inst = await getAppInstallation(slug)
+ if (!inst) {
+ logFail(`17: app '${slug}' is not installed on org ${ORG} via enterprise '${GH_ENTERPRISE}' — pre-create and install it`)
+ continue
+ }
+ appInstallSnapshot[slug] = {
+ installationId: inst.id,
+ selection: inst.repository_selection,
+ repos: inst.repository_selection === 'selected' ? await listInstallationRepoNames(inst.id) : []
+ }
+ }
+
+ const primary = appInstallSnapshot[app]
+ if (!primary) {
+ logFail(`17: primary app '${app}' installation not found — aborting app_installations tests`)
+ return
+ }
+ const installationId = primary.installationId
+ log(`17: primary app '${app}' installation id=${installationId}, initial selection='${primary.selection}'`)
+
+ log('17: Ensuring dedicated app-install smoke repos exist...')
+ await ensureRepo(APP_TEST_REPOS[0])
+ await ensureRepo(APP_TEST_REPOS[1])
+
+ // ── 17a: Org-level repository_selection: all ───────────────────────────────
+ {
+ log('17a: Setting app to repository_selection: all via org settings.yml')
+ const branch = 'smoke-test-phase17a'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, settingsAppInstallAll(app), branch, '17a: app_installations repository_selection all')
+ const pr = await createPR(ORG, ADMIN_REPO, '17a: app_installations org-level all', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '17a: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `17a: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const selAll = await poll(async () => {
+ const i = await getAppInstallation(app)
+ return (i && i.repository_selection === 'all') ? i : null
+ }, { desc: "app installation to be set to 'all'", timeout: 90000 })
+ assert(selAll !== null, "17a: app repository_selection set to 'all' by org-level config")
+
+ // Isolation: any other configured app must be unchanged by app[0]'s config.
+ for (const slug of APP_SLUGS.slice(1)) {
+ const snap = appInstallSnapshot[slug]
+ if (!snap) continue
+ const other = await getAppInstallation(slug)
+ assert(other !== null && other.repository_selection === snap.selection,
+ `17a: other app '${slug}' repository_selection unchanged (isolation)`)
+ }
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
+ // ── 17b: Repo-level selection for two repos (full sync via settings.yml) ────
+ // Remove the org-level 'all' and drive selection entirely from repo configs.
+ // A settings.yml change triggers a full sync, which recomputes desired state
+ // from all layers and narrows the installation from 'all' → 'selected'.
+ {
+ log('17b: Narrowing app to two specific repos via repo-level configs')
+ const branch = 'smoke-test-phase17b'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, settingsAppInstallEmpty(), branch, '17b: clear org app_installations')
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/${APP_TEST_REPOS[0]}.yml`, repoAppInstallConfig(APP_TEST_REPOS[0], app), branch, `17b: add ${APP_TEST_REPOS[0]} to app`)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/${APP_TEST_REPOS[1]}.yml`, repoAppInstallConfig(APP_TEST_REPOS[1], app), branch, `17b: add ${APP_TEST_REPOS[1]} to app`)
+ const pr = await createPR(ORG, ADMIN_REPO, '17b: app_installations repo-level selection', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '17b: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `17b: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const selected = await poll(async () => {
+ const i = await getAppInstallation(app)
+ if (!i || i.repository_selection !== 'selected') return null
+ const repos = await listInstallationRepoNames(i.id)
+ return (repos.includes(APP_TEST_REPOS[0]) && repos.includes(APP_TEST_REPOS[1])) ? repos : null
+ }, { desc: "app installation to be 'selected' with the two repos", timeout: 90000 })
+ assert(selected !== null, `17b: app narrowed to 'selected' with ${APP_TEST_REPOS[0]} and ${APP_TEST_REPOS[1]}`)
+ }
+
+ // ── 17c: Add a third repo via a new repo config ────────────────────────────
+ {
+ log('17c: Adding a third repo to the app via a new repo config')
+ await ensureRepo(APP_TEST_REPOS[2])
+ const branch = 'smoke-test-phase17c'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ // Bump settings.yml to force a full sync that includes the new repo config.
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, settingsAppInstallEmpty('bump 17c'), branch, '17c: bump settings to trigger full sync')
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/${APP_TEST_REPOS[2]}.yml`, repoAppInstallConfig(APP_TEST_REPOS[2], app), branch, `17c: add ${APP_TEST_REPOS[2]} to app`)
+ const pr = await createPR(ORG, ADMIN_REPO, '17c: app_installations add repo', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '17c: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `17c: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const added = await poll(async () => {
+ const i = await getAppInstallation(app)
+ if (!i || i.repository_selection !== 'selected') return null
+ const repos = await listInstallationRepoNames(i.id)
+ return repos.includes(APP_TEST_REPOS[2]) ? repos : null
+ }, { desc: `${APP_TEST_REPOS[2]} to be added to the app installation`, timeout: 90000 })
+ assert(added !== null, `17c: ${APP_TEST_REPOS[2]} added to app installation`)
+ }
+
+ // ── 17d: Remove the third repo by deleting its config ──────────────────────
+ {
+ log('17d: Removing the third repo from the app by deleting its repo config')
+ const branch = 'smoke-test-phase17d'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await deleteFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/${APP_TEST_REPOS[2]}.yml`, branch, `17d: remove ${APP_TEST_REPOS[2]} config`)
+ // Bump settings.yml to force a full sync (removals are reconciled by full sync).
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, settingsAppInstallEmpty('bump 17d'), branch, '17d: bump settings to trigger full sync')
+ const pr = await createPR(ORG, ADMIN_REPO, '17d: app_installations remove repo', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '17d: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `17d: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const removed = await poll(async () => {
+ const i = await getAppInstallation(app)
+ if (!i || i.repository_selection !== 'selected') return null
+ const repos = await listInstallationRepoNames(i.id)
+ return !repos.includes(APP_TEST_REPOS[2]) ? repos : null
+ }, { desc: `${APP_TEST_REPOS[2]} to be removed from the app installation`, timeout: 90000 })
+ assert(removed !== null, `17d: ${APP_TEST_REPOS[2]} removed from app installation`)
+ }
+
+ // ── 17e: Drift remediation via full sync ───────────────────────────────────
+ // Manually add an unmanaged repo to the installation, then trigger a full
+ // sync (settings.yml bump). Full sync must remove the drifted repo since it
+ // is not in any config layer's desired state.
+ {
+ log(`17e: Injecting drift — adding unmanaged ${APP_TEST_REPOS[2]} to the installation directly`)
+ try {
+ await addInstallationRepos(installationId, [APP_TEST_REPOS[2]])
+ } catch (e) { logFail(`17e: could not inject drift: ${e.message}`) }
+
+ const driftPresent = await poll(async () => {
+ const repos = await listInstallationRepoNames(installationId)
+ return repos.includes(APP_TEST_REPOS[2]) ? repos : null
+ }, { desc: `drifted ${APP_TEST_REPOS[2]} to be present before remediation`, timeout: 30000 })
+ assert(driftPresent !== null, `17e: drift injected (${APP_TEST_REPOS[2]} present on installation)`)
+
+ const branch = 'smoke-test-phase17e'
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, settingsAppInstallEmpty('bump 17e'), branch, '17e: bump settings to trigger full sync drift remediation')
+ const pr = await createPR(ORG, ADMIN_REPO, '17e: app_installations drift remediation', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, '17e: NOP check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `17e: NOP check run is success (got: ${checkRun.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const driftRemoved = await poll(async () => {
+ const repos = await listInstallationRepoNames(installationId)
+ return !repos.includes(APP_TEST_REPOS[2]) ? repos : null
+ }, { desc: `drifted ${APP_TEST_REPOS[2]} to be removed by full sync`, timeout: 90000 })
+ assert(driftRemoved !== null, `17e: drift remediated — ${APP_TEST_REPOS[2]} removed by full sync`)
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ }
+
+ // ── 17f: Sub-org targeting (delta sync via suborgs/*.yml) ──────────────────
+ // A suborg config change triggers the delta sync path (not full sync). The
+ // suborg's targeting (here suborgrepos) resolves the repos to add/remove for
+ // the app. Entering this block the installation is 'selected' with
+ // APP_TEST_REPOS[0] and [1]; [2] is not selected.
+ {
+ // Step A: add a suborg that targets repo-3 and adds the app → repo-3 added.
+ log('17f: Adding a repo to the app via suborg targeting (suborgrepos, delta sync)')
+ const branchA = 'smoke-test-phase17f-add'
+ await deleteBranch(ORG, ADMIN_REPO, branchA)
+ await createBranch(ORG, ADMIN_REPO, branchA)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/smoke-app-suborg.yml`, suborgAppInstallConfig([APP_TEST_REPOS[2]], app), branchA, '17f: suborg targeting adds smoke-app-repo-3 to app')
+ const prA = await createPR(ORG, ADMIN_REPO, '17f: app_installations suborg add', branchA, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRunA = await waitForCheckRun(ORG, ADMIN_REPO, prA.head.sha)
+ assert(checkRunA !== null, '17f: NOP check run completed (suborg add)')
+ if (checkRunA) assert(checkRunA.conclusion === 'success', `17f: NOP check run is success (got: ${checkRunA.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, prA.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const suborgAdded = await poll(async () => {
+ const i = await getAppInstallation(app)
+ if (!i || i.repository_selection !== 'selected') return null
+ const repos = await listInstallationRepoNames(i.id)
+ return repos.includes(APP_TEST_REPOS[2]) ? repos : null
+ }, { desc: `${APP_TEST_REPOS[2]} to be added via suborg targeting`, timeout: 90000 })
+ assert(suborgAdded !== null, `17f: ${APP_TEST_REPOS[2]} added to app via suborg targeting (delta sync)`)
+
+ await deleteBranch(ORG, ADMIN_REPO, branchA)
+
+ // Step B: narrow the suborg targeting so repo-3 drops out → repo-3 removed.
+ // Retarget to repo-1 (already selected by its repo config, so a no-op add);
+ // the delta unselection must remove repo-3.
+ log('17f: Narrowing suborg targeting so smoke-app-repo-3 drops out (delta unselection)')
+ const branchB = 'smoke-test-phase17f-narrow'
+ await deleteBranch(ORG, ADMIN_REPO, branchB)
+ await createBranch(ORG, ADMIN_REPO, branchB)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/smoke-app-suborg.yml`, suborgAppInstallConfig([APP_TEST_REPOS[0]], app), branchB, '17f: narrow suborg targeting to smoke-app-repo-1')
+ const prB = await createPR(ORG, ADMIN_REPO, '17f: app_installations suborg narrow', branchB, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRunB = await waitForCheckRun(ORG, ADMIN_REPO, prB.head.sha)
+ assert(checkRunB !== null, '17f: NOP check run completed (suborg narrow)')
+ if (checkRunB) assert(checkRunB.conclusion === 'success', `17f: NOP check run is success (got: ${checkRunB.conclusion})`)
+ if (!await safeMerge(ORG, ADMIN_REPO, prB.number)) return
+ await sleep(WEBHOOK_SETTLE_MS + 15000)
+
+ const suborgRemoved = await poll(async () => {
+ const i = await getAppInstallation(app)
+ if (!i || i.repository_selection !== 'selected') return null
+ const repos = await listInstallationRepoNames(i.id)
+ return !repos.includes(APP_TEST_REPOS[2]) ? repos : null
+ }, { desc: `${APP_TEST_REPOS[2]} to be removed when dropped from suborg targeting`, timeout: 90000 })
+ assert(suborgRemoved !== null, `17f: ${APP_TEST_REPOS[2]} removed when dropped from suborg targeting (delta unselection)`)
+
+ await deleteBranch(ORG, ADMIN_REPO, branchB)
+ }
+}
+
// ─── Main ────────────────────────────────────────────────────────────────────
async function main () {
@@ -2710,7 +3103,8 @@ async function main () {
// Find installation for our org
let installationId
for await (const { installation } of app.eachInstallation.iterator()) {
- if (installation.account && installation.account.login.toLowerCase() === ORG.toLowerCase()) {
+ // Org/user installations key off account.login (only enterprise accounts have a slug).
+ if (installation.account && installation.account.login && installation.account.login.toLowerCase() === ORG.toLowerCase()) {
installationId = installation.id
break
}
@@ -2720,6 +3114,22 @@ async function main () {
octokit = await app.getInstallationOctokit(installationId)
log('Authenticated as GitHub App installation')
+ // Optionally resolve the enterprise installation for the app_installations
+ // plugin phase. The same App must be installed on the enterprise with the
+ // "Enterprise organization installations" permission.
+ if (GH_ENTERPRISE && APP_SLUGS.length > 0) {
+ for await (const { installation } of app.eachInstallation.iterator()) {
+ if (installation.target_type === 'Enterprise' &&
+ installation.account && installation.account.slug &&
+ installation.account.slug.toLowerCase() === GH_ENTERPRISE.toLowerCase()) {
+ entOctokit = await app.getInstallationOctokit(installation.id)
+ log(`Authenticated as enterprise installation for '${GH_ENTERPRISE}' (app_installations phase enabled)`)
+ break
+ }
+ }
+ if (!entOctokit) log(`\x1b[33m⚠ No enterprise installation found for '${GH_ENTERPRISE}' — Phase 17 will report a failure.\x1b[0m`)
+ }
+
console.log(`
\x1b[36m╔══════════════════════════════════════╗
║ Safe-Settings Smoke Test ║
@@ -2752,7 +3162,8 @@ async function main () {
['Phase 13: variables', phase13Variables],
['Phase 14: regressions', phase14RegressionCoverage],
['Phase 15: Ruleset array drift', phase15RulesetArrayDrift],
- ['Phase 16: Ruleset name/slug resolution', phase16RulesetNameResolution]
+ ['Phase 16: Ruleset name/slug resolution', phase16RulesetNameResolution],
+ ['Phase 17: App installation management', phase17AppInstallations]
]
// When --phase is given, only run setup (phase 0) + the requested phase(s).
From 47a8d116138a88c9346e85f18a702331ca18980f Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Mon, 6 Jul 2026 18:43:12 -0400
Subject: [PATCH 46/67] ADR updates
---
docs/adr/0001-app-installation-plugin.md | 67 +++++++++++++++++++++++-
1 file changed, 65 insertions(+), 2 deletions(-)
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index e8ab7d1ca..b7921ad13 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -2,6 +2,9 @@
- Status: Accepted
- Date: 2026-07-06
+- Last updated: 2026-07-06 — added reporting subject model, per-repo pipeline
+ exclusion, startup verification, non-managed-app safety guarantee, and smoke
+ tests.
- Deciders: safe-settings maintainers
- Related PR: `decyjphr-app-installation-plugin`
@@ -67,8 +70,9 @@ app_installations:
| `lib/plugins/appInstallations.js` | Reconcile desired vs. live repo access per app (`syncDelta` / `syncFull`). Not `Diffable` — app installations are an org-scoped target, not a per-repo list. |
| `lib/appOctokitClient.js` | Enterprise-level App client for the Enterprise Organization Installations API. |
| `lib/repoSelector.js` | Resolve a repo set from **fixed** criteria: name, team, custom properties, or "all". |
-| `lib/settings.js` | `syncAppInstallations` phase; delta computation from changed configs; full desired-state computation. |
-| `index.js` | Enterprise-client enrichment on the context; `installation_target` webhook handler. |
+| `lib/settings.js` | `syncAppInstallations` phase; delta computation from changed configs; full desired-state computation; app-aware NOP reporting. |
+| `lib/nopcommand.js` | Carries an optional `subject` / `subjectType` so reporting can render the **app** (not the org placeholder repo) as the subject of a change. |
+| `index.js` | Enterprise-client enrichment on the context (`getEnterpriseAppClient`); `installation_target` webhook handler; startup `verifyAppInstallationsPlugin` diagnostic. |
### Sync model: delta vs. full
@@ -101,6 +105,27 @@ API (version `2026-03-10`) and operate on repository **names**:
Add/remove are capped at **50 repos per call** and are auto-batched.
+### Reporting (NOP / PR check-run)
+
+The rest of safe-settings reports changes **per repository**. App installation
+changes have no meaningful repository subject — the NopCommands are emitted
+against the ` (org)` placeholder repo, which rendered confusingly (e.g. a
+`**admin**` heading with a nested `value: (all repositories)` row).
+
+To fix this without a disruptive rename of the repo-centric reporting pipeline,
+`NopCommand` gained an **optional, additive `subject` / `subjectType`**:
+
+- `subject` defaults to the repo, so every existing plugin is unaffected.
+- `app_installations` sets `subject = `, `subjectType = 'app'`.
+- Reporting groups changes by `subject` (identical to the repo for all other
+ plugins), so each **app** becomes its own heading.
+- For `app_installations`, the impact summary pluralizes **apps** (not repos),
+ rows render as a flat `+`/`-` list of repositories, and the section is
+ excluded from the "repos affected" count (app subjects must not inflate it).
+- The `NopCommand` constructor accepts the `subject` object in the `type`
+ position (defaulting `type` to `INFO`) so callers can pass a subject without
+ restating the default type.
+
## Decisions and rationale
1. **Enterprise auth is a prerequisite, not a config knob.**
@@ -150,6 +175,35 @@ Add/remove are capped at **50 repos per call** and are auto-batched.
is *not* a repository, paving the way for future targets (e.g., Copilot
policies) to reuse the same phase/plumbing without being repo-bound.
+11. **`app_installations` is excluded from the per-repo plugin pipeline.**
+ Although it is registered in `Settings.PLUGINS` (so `disable_plugins` /
+ `additive_plugins` name-validation and suborg-cleanup detection recognize
+ it), `childPluginsList` explicitly skips it. The per-repo pipeline calls
+ `instance.sync()`, which `AppInstallations` does not implement (it exposes
+ `syncDelta` / `syncFull` and has a different constructor signature). It is
+ reconciled **only** through the dedicated `syncAppInstallations` phase.
+
+12. **App is the reporting subject.** See *Reporting* above — an additive
+ `NopCommand.subject` avoids a global rename of the repo-centric pipeline
+ while presenting app installation changes with the app as the subject and
+ keeping repo counts accurate.
+
+13. **Startup verification.** On boot, `index.js` runs
+ `verifyAppInstallationsPlugin`: when `GH_ENTERPRISE` is set it mints an
+ enterprise installation token and confirms it can list app installations in
+ the target org (`GH_ORG`), logging a clear success/failure. When
+ `GH_ENTERPRISE` is unset the check is skipped, so non-enterprise
+ deployments are unaffected. This surfaces a mis-scoped enterprise install
+ early rather than at first sync.
+
+14. **Only explicitly-named apps are ever touched.** Both full and delta sync
+ operate solely on apps that appear in an `app_installations` entry in some
+ config layer. `listOrgInstallations` is used only to resolve
+ `app_slug → installation_id`; it never seeds the desired state, and there is
+ no "remove apps not in config" sweep. Consequently the safe-settings app's
+ own installation (and every other unlisted app) is left untouched on every
+ sync unless a config explicitly names it.
+
## Consequences
### Positive
@@ -159,6 +213,15 @@ Add/remove are capped at **50 repos per call** and are auto-batched.
- Names-based API + native "all" toggle removes an entire class of ID-resolution
and enumeration work.
- Batching respects the 50-repo API limit transparently.
+- App installation changes read clearly in PR comments (app as subject) without
+ reworking the repo-centric reporting pipeline or distorting repo counts.
+- Unlisted apps — including safe-settings itself — are provably never modified,
+ so enabling the plugin cannot accidentally lock the app out of repositories.
+- A startup self-check catches missing/mis-scoped enterprise permissions before
+ the first sync.
+- Smoke coverage (`smoke-test.js` Phase 17) exercises org-level `all`,
+ repo-level selection, add/remove, drift remediation via full sync, and
+ sub-org (delta) targeting, restoring each app's original state on teardown.
### Negative / limitations
From 783269468ba1ae381f50d9c0b56bbf79e1047c0a Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 08:08:45 -0400
Subject: [PATCH 47/67] Refactor enterprise installation handling: update
caching logic and improve logging
---
app.yml | 73 ++++++++++++++++++++++++++++++--------------------------
index.js | 29 +++++++++++++---------
2 files changed, 57 insertions(+), 45 deletions(-)
diff --git a/app.yml b/app.yml
index e61ffdf30..9402d1df1 100644
--- a/app.yml
+++ b/app.yml
@@ -31,13 +31,14 @@ default_events:
# the value (for example, write).
# Valid values are `read`, `write`, and `none`
default_permissions:
- repository_custom_properties: write
- organization_custom_properties: admin
-
# Workflows, workflow runs and artifacts. (needed to read environments when repo is private or internal)
# https://developer.github.com/v3/apps/permissions/#repository-permissions-for-actions
actions: read
+ # Manage Actions variables.
+ # https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28
+ actions_variables: write
+
# Repository creation, deletion, settings, teams, and collaborators.
# https://developer.github.com/v3/apps/permissions/#permission-on-administration
administration: write
@@ -54,6 +55,10 @@ default_permissions:
# https://developer.github.com/v3/apps/permissions/#permission-on-deployments
# deployments: read
+ enterprise_organization_installations: write
+
+ enterprise_organization_installation_repositories: write
+
# Manage repository environments.
# https://developer.github.com/v3/apps/permissions/#repository-permissions-for-environments
environments: write
@@ -62,38 +67,14 @@ default_permissions:
# https://developer.github.com/v3/apps/permissions/#permission-on-issues
issues: write
- # Search repositories, list collaborators, and access repository metadata.
- # https://developer.github.com/v3/apps/permissions/#metadata-permissions
- metadata: read
-
- # Retrieve Pages statuses, configuration, and builds, as well as create new builds.
- # https://developer.github.com/v3/apps/permissions/#permission-on-pages
- # pages: read
-
- # Pull requests and related comments, assignees, labels, milestones, and merges.
- # https://developer.github.com/v3/apps/permissions/#permission-on-pull-requests
- pull_requests: write
-
- # Manage the post-receive hooks for a repository.
- # https://developer.github.com/v3/apps/permissions/#permission-on-repository-hooks
- # repository_hooks: read
-
- # Manage repository projects, columns, and cards.
- # https://developer.github.com/v3/apps/permissions/#permission-on-repository-projects
- # repository_projects: read
-
- # Retrieve security vulnerability alerts.
- # https://developer.github.com/v4/object/repositoryvulnerabilityalert/
- # vulnerability_alerts: read
-
- # Commit statuses.
- # https://developer.github.com/v3/apps/permissions/#permission-on-statuses
- statuses: write
-
# Organization members and teams.
# https://developer.github.com/v3/apps/permissions/#permission-on-members
members: write
+ # Search repositories, list collaborators, and access repository metadata.
+ # https://developer.github.com/v3/apps/permissions/#metadata-permissions
+ metadata: read
+
# View and manage users blocked by the organization.
# https://developer.github.com/v3/apps/permissions/#permission-on-organization-user-blocking
# organization_user_blocking: read
@@ -122,9 +103,33 @@ default_permissions:
# https://docs.github.com/en/enterprise-cloud@latest/rest/authentication/permissions-required-for-github-apps?apiVersion=2026-03-10#organization-permissions-for-custom-repository-roles
organization_custom_roles: write
- # Manage Actions variables.
- # https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28
- actions_variables: write
+ organization_custom_properties: admin
+
+ # Retrieve Pages statuses, configuration, and builds, as well as create new builds.
+ # https://developer.github.com/v3/apps/permissions/#permission-on-pages
+ # pages: read
+
+ # Pull requests and related comments, assignees, labels, milestones, and merges.
+ # https://developer.github.com/v3/apps/permissions/#permission-on-pull-requests
+ pull_requests: write
+
+ repository_custom_properties: write
+
+ # Manage the post-receive hooks for a repository.
+ # https://developer.github.com/v3/apps/permissions/#permission-on-repository-hooks
+ # repository_hooks: read
+
+ # Manage repository projects, columns, and cards.
+ # https://developer.github.com/v3/apps/permissions/#permission-on-repository-projects
+ # repository_projects: read
+
+ # Retrieve security vulnerability alerts.
+ # https://developer.github.com/v4/object/repositoryvulnerabilityalert/
+ # vulnerability_alerts: read
+
+ # Commit statuses.
+ # https://developer.github.com/v3/apps/permissions/#permission-on-statuses
+ statuses: write
# The name of the GitHub App. Defaults to the name specified in package.json
diff --git a/index.js b/index.js
index b44db0430..6e99239b8 100644
--- a/index.js
+++ b/index.js
@@ -13,7 +13,10 @@ let deploymentConfig
module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
let appSlug = 'safe-settings'
- let cachedEnterpriseInstallationId = null
+ // Cache of enterprise slug → enterprise installation id. Keyed by slug so a
+ // cached id is never reused for a different enterprise (e.g. when the app
+ // handles events from multiple enterprises).
+ const cachedEnterpriseInstallationIds = new Map()
async function syncAllSettings (nop, context, repo = context.repo(), ref, baseRef, changedFiles = {}) {
try {
deploymentConfig = await loadYamlFileSystem()
@@ -155,9 +158,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
* client authenticated with the enterprise installation token, along with
* the installation ID.
*
- * Uses the cached enterprise installation ID when available to avoid
- * re-listing installations. Returns null if no matching enterprise
- * installation is found.
+ * Uses the cached enterprise installation ID for the given slug when
+ * available to avoid re-listing installations. The cache is keyed by
+ * enterprise slug so an id is never reused across enterprises. Returns null
+ * if no matching enterprise installation is found.
*
* @param {string} enterpriseSlug - Enterprise slug
* @returns {Promise<{ appGithub: object, installationId: number } | null>}
@@ -165,10 +169,13 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
async function getEnterpriseAppClient (enterpriseSlug) {
if (!enterpriseSlug) return null
- // Use cached enterprise installation ID if available
- if (cachedEnterpriseInstallationId) {
- const appGithub = await robot.auth(cachedEnterpriseInstallationId)
- return { appGithub, installationId: cachedEnterpriseInstallationId }
+ // Use the cached enterprise installation id for THIS slug if available.
+ // Keying by slug ensures a cached id is never reused for a different
+ // enterprise.
+ const cachedId = cachedEnterpriseInstallationIds.get(enterpriseSlug)
+ if (cachedId) {
+ const appGithub = await robot.auth(cachedId)
+ return { appGithub, installationId: cachedId }
}
// Get a JWT-authenticated client to list all installations
@@ -183,7 +190,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
if (!enterpriseInstallation) {
return null
}
- cachedEnterpriseInstallationId = enterpriseInstallation.id
+ cachedEnterpriseInstallationIds.set(enterpriseSlug, enterpriseInstallation.id)
const enterpriseGithub = await robot.auth(enterpriseInstallation.id)
return { appGithub: enterpriseGithub, installationId: enterpriseInstallation.id }
}
@@ -310,7 +317,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const github = await robot.auth(installation.id)
const app = await github.apps.getAuthenticated()
appSlug = app.data.slug
- robot.log.debug(`Validated the app is configured properly = \n${JSON.stringify(app.data, null, 2)}`)
+ robot.log.info(`Validated the app is configured properly = \n${JSON.stringify(app.data, null, 2)}`)
}
await verifyAppInstallationsPlugin()
@@ -331,7 +338,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
async function verifyAppInstallationsPlugin () {
const enterpriseSlug = process.env.GH_ENTERPRISE
if (!enterpriseSlug) {
- robot.log.debug('GH_ENTERPRISE is not set — skipping app-installations plugin verification')
+ robot.log.info('GH_ENTERPRISE is not set — skipping app-installations plugin verification')
return
}
From 366522bed0c3d3e197b8da84520d435ce8cf1ce2 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:23:31 -0400
Subject: [PATCH 48/67] Add functions to list installations and find enterprise
installation
---
index.js | 46 +++++++++++++++++++++++++++++-----------------
1 file changed, 29 insertions(+), 17 deletions(-)
diff --git a/index.js b/index.js
index 6e99239b8..d0224f4c1 100644
--- a/index.js
+++ b/index.js
@@ -153,6 +153,32 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
}
+ /**
+ * Lists all installations of the app using a JWT-authenticated client.
+ *
+ * @returns {Promise} All app installations
+ */
+ async function listAllInstallations () {
+ const github = await robot.auth()
+ return github.paginate(
+ github.apps.listInstallations.endpoint.merge({ per_page: 100 })
+ )
+ }
+
+ /**
+ * Finds the enterprise installation matching the given slug from the app's
+ * installation list. Returns null if none matches.
+ *
+ * @param {string} enterpriseSlug - Enterprise slug
+ * @returns {Promise} The matching enterprise installation
+ */
+ async function findEnterpriseInstallation (enterpriseSlug) {
+ const installations = await listAllInstallations()
+ return installations.find(
+ i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterpriseSlug
+ ) || null
+ }
+
/**
* Finds the enterprise installation for a given slug and returns an Octokit
* client authenticated with the enterprise installation token, along with
@@ -178,15 +204,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return { appGithub, installationId: cachedId }
}
- // 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 === enterpriseSlug
- )
+ const enterpriseInstallation = await findEnterpriseInstallation(enterpriseSlug)
if (!enterpriseInstallation) {
return null
}
@@ -307,10 +326,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
async function info () {
- const github = await robot.auth()
- const installations = await github.paginate(
- github.apps.listInstallations.endpoint.merge({ per_page: 100 })
- )
+ const installations = await listAllInstallations()
robot.log.debug(`installations: ${JSON.stringify(installations)}`)
if (installations.length > 0) {
const installation = installations[0]
@@ -371,11 +387,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
async function syncInstallation (nop = false) {
robot.log.trace('Fetching installations')
- const github = await robot.auth()
-
- const installations = await github.paginate(
- github.apps.listInstallations.endpoint.merge({ per_page: 100 })
- )
+ const installations = await listAllInstallations()
if (installations.length > 0) {
const installation = installations[0]
From b4f00b24e10caec7e37757884508da67d7a840d9 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:30:31 -0400
Subject: [PATCH 49/67] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
index.js | 27 +++++++++++++++------------
1 file changed, 15 insertions(+), 12 deletions(-)
diff --git a/index.js b/index.js
index d0224f4c1..02b8a136f 100644
--- a/index.js
+++ b/index.js
@@ -224,19 +224,22 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
*/
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 {
- const result = await getEnterpriseAppClient(enterprise.slug)
- if (result) {
- context.appGithub = result.appGithub
- } 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}`)
+ const slugFromPayload = (payload.enterprise && payload.enterprise.slug) ||
+ (payload.installation && payload.installation.enterprise && payload.installation.enterprise.slug)
+ const enterpriseSlug = slugFromPayload || process.env.GH_ENTERPRISE
+
+ if (!enterpriseSlug) return
+
+ context.enterpriseSlug = enterpriseSlug
+ try {
+ const result = await getEnterpriseAppClient(enterpriseSlug)
+ if (result) {
+ context.appGithub = result.appGithub
+ } else {
+ robot.log.debug(`No enterprise installation found for slug '${enterpriseSlug}'. App installation management will not be available.`)
}
+ } catch (e) {
+ robot.log.debug(`Could not create enterprise-authenticated client: ${e.message}`)
}
}
From b6b87a0f98cff9188e0e0915c772b2c326937941 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:20:16 -0400
Subject: [PATCH 50/67] Resolved CCR PR review
---
lib/settings.js | 10 +++++++++-
schema/settings.json | 2 +-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 2f41e5e97..6d5834be4 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1503,7 +1503,8 @@ class Settings {
}
if (!enterpriseSlug) {
- const msg = 'Cannot sync app installations: enterprise slug not available in webhook payload. Ensure the webhook is from an enterprise-managed org.'
+ const msg = 'Cannot sync app installations: enterprise slug not available in context (webhook payload missing enterprise info and no fallback configured).'
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
this.log.error(msg)
if (this.nop) {
this.appendToResults([new NopCommand('app_installations', this.repo, null, msg, 'ERROR')])
@@ -1511,6 +1512,13 @@ class Settings {
return
}
+ if (!appGithub) {
+ const msg = `Cannot sync app installations: enterprise-authenticated client not available for '${enterpriseSlug}'. Ensure safe-settings is installed on the enterprise with 'Enterprise organization installations' permission.`
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ this.log.error(msg)
+ if (this.nop) this.appendToResults([new NopCommand('app_installations', this.repo, null, msg, 'ERROR')])
+ return
+ }
const additiveSet = this.normalizeAdditivePlugins()
const plugin = new AppInstallations(
this.nop,
diff --git a/schema/settings.json b/schema/settings.json
index 0c349a5d4..7f611f476 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -259,7 +259,7 @@
}
},
"additive_plugins": {
- "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). The app_installations plugin also honors additive mode (only adds repos to installations, never removes). Declare only in settings.yml (org level) to keep behavior consistent across all repos.",
+ "description": "List of plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Supported plugins: labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles, app_installations. Declare only in settings.yml (org level) to keep behavior consistent across all repos.",
"type": "array",
"items": {
"type": "string",
From d22a410fe0ffdb4aa4b2828c9ba7b0eaa4986813 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:58:45 -0400
Subject: [PATCH 51/67] Removed repository_selection as a field in the
settings.yml
---
README.md | 5 ++---
docs/adr/0001-app-installation-plugin.md | 17 +++++++++++------
lib/settings.js | 13 ++++++-------
schema/settings.json | 9 +--------
smoke-test.js | 3 +--
5 files changed, 21 insertions(+), 26 deletions(-)
diff --git a/README.md b/README.md
index 86314547e..877f6c298 100644
--- a/README.md
+++ b/README.md
@@ -692,12 +692,12 @@ are selected for the app:
| Layer | File | Repos selected for the app |
| --- | --- | --- |
-| Org | `settings.yml` | All repos in the org (`repository_selection: all`) |
+| Org | `settings.yml` | All repos in the org |
| Suborg | `suborgs/*.yml` | Repos matching the suborg's targeting (`suborgrepos`, `suborgteams`, `suborgproperties`) |
| Repo | `repos/.yml` | That specific repo |
> [!important]
-> An app configured with `repository_selection: all` at the **org** level takes
+> An app listed at the **org** level (which implies all repos) takes
> precedence. Suborg/repo-level selections for that same app are ignored, and
> repos are never removed from it by incremental (suborg/repo) changes — it is
> reconciled only by the full (scheduled) sync.
@@ -709,7 +709,6 @@ Org-level `settings.yml` — give an app access to **all** repos in the org:
```yaml
app_installations:
- app_slug: my-internal-app
- repository_selection: all
```
Suborg-level `suborgs/backend.yml` — give an app access to the repos targeted
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index b7921ad13..42664f970 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -2,9 +2,10 @@
- Status: Accepted
- Date: 2026-07-06
-- Last updated: 2026-07-06 — added reporting subject model, per-repo pipeline
- exclusion, startup verification, non-managed-app safety guarantee, and smoke
- tests.
+- Last updated: 2026-07-07 — added reporting subject model, per-repo pipeline
+ exclusion, startup verification, non-managed-app safety guarantee, smoke
+ tests, and removed the redundant `repository_selection` config attribute
+ (org level is implicitly "all").
- Deciders: safe-settings maintainers
- Related PR: `decyjphr-app-installation-plugin`
@@ -19,7 +20,8 @@ applied by `syncAll` / `syncSelectedRepos` / `sync`.
We need a new capability where the **target of the operation is a GitHub App
installation** rather than a repository. Concretely, safe-settings should
declaratively control **which repositories each installed GitHub App can
-access** (`repository_selection`), driven by the same config hierarchy:
+access** (the installation's repository access), driven by the same config
+hierarchy:
- **Org-level `settings.yml`** → the app should have access to **all** repos in
the org.
@@ -146,8 +148,11 @@ To fix this without a disruptive rename of the repo-centric reporting pipeline,
reuse existing `getReposForTeam` / `getRepositoriesByProperty` patterns.
4. **Org-level "all" takes precedence** over any suborg/repo-level selection or
- exclusion. If an app is "all" at org level, the installation is toggled to
- `all` and deltas for that app are skipped.
+ exclusion. An org-level `app_installations` entry lists only the `app_slug`
+ — it always implies **all** repos (there is no `repository_selection` config
+ attribute; it was removed as redundant). When an app is named at the org
+ level, the installation is toggled to `all` and deltas for that app are
+ skipped.
5. **Repository NAMES, not IDs.** The Enterprise Org Installations API accepts
names, so the plugin no longer resolves names → IDs or enumerates all repos
diff --git a/lib/settings.js b/lib/settings.js
index 6d5834be4..7ca9b5a51 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1589,7 +1589,8 @@ class Settings {
const orgAppInstallations = this.config && this.config.app_installations
if (Array.isArray(orgAppInstallations)) {
for (const appConfig of orgAppInstallations) {
- if (appConfig && appConfig.app_slug && appConfig.repository_selection === 'all') {
+ // Any org-level app_installations entry always implies 'all'.
+ if (appConfig && appConfig.app_slug) {
orgAllApps.add(appConfig.app_slug)
}
}
@@ -1771,7 +1772,9 @@ class Settings {
selectionMap.set(inst.app_slug, inst.repository_selection)
}
- // Process org-level config
+ // Process org-level config. An org-level app_installations entry always
+ // implies access to ALL repos in the org (there is no per-repo selection
+ // at this layer).
for (const appConfig of orgAppInstallations) {
const slug = appConfig.app_slug
if (!slug) continue
@@ -1782,11 +1785,7 @@ class Settings {
continue
}
- if (appConfig.repository_selection === 'all') {
- desiredState[slug] = { installation_id: installationId, repos: 'all' }
- } else {
- desiredState[slug] = { installation_id: installationId, repos: new Set() }
- }
+ desiredState[slug] = { installation_id: installationId, repos: 'all' }
}
// Overlay suborg-level configs
diff --git a/schema/settings.json b/schema/settings.json
index 7f611f476..02c8b36fd 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -235,7 +235,7 @@
}
},
"app_installations": {
- "description": "Manage which repositories a GitHub App installation can access. The target is a GitHub App installation rather than a repository. Repo selection follows the config hierarchy: org-level settings.yml selects all repos (repository_selection: all); suborgs/*.yml selects repos by the suborg's targeting criteria; repos/*.yml adds the specific repo. Requires safe-settings to be installed on the enterprise with 'Enterprise organization installations' permission.",
+ "description": "Manage which repositories a GitHub App installation can access. The target is a GitHub App installation rather than a repository. Repo selection follows the config hierarchy: org-level settings.yml selects all repos in the org; suborgs/*.yml selects repos by the suborg's targeting criteria; repos/*.yml adds the specific repo. Requires safe-settings to be installed on the enterprise with 'Enterprise organization installations' permission.",
"type": "array",
"items": {
"type": "object",
@@ -247,13 +247,6 @@
"app_slug": {
"type": "string",
"description": "The slug of the GitHub App installation to manage."
- },
- "repository_selection": {
- "type": "string",
- "enum": [
- "all"
- ],
- "description": "Only valid at the org level (settings.yml). 'all' selects every repository in the org and takes precedence over any suborg/repo-level selections."
}
}
}
diff --git a/smoke-test.js b/smoke-test.js
index 48739179a..2b4d9dba7 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -2806,10 +2806,9 @@ async function phase16RulesetNameResolution () {
// ─── App installation config builders (Phase 17) ─────────────────────────────
// Org-level settings.yml giving an app access to ALL repos.
-const settingsAppInstallAll = (slug) => `# App installations: org-level repository_selection all
+const settingsAppInstallAll = (slug) => `# App installations: org-level (implies all repos)
app_installations:
- app_slug: ${slug}
- repository_selection: all
`
// Empty org settings (no app_installations) with an optional comment bump to
From 31652a745157a592dd50350e620d2beace22331f Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 14:58:53 -0400
Subject: [PATCH 52/67] Enhance app_installations handling: add detection for
layered configurations and report unknown apps in logs
---
lib/settings.js | 56 ++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 51 insertions(+), 5 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 7ca9b5a51..66ad30859 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1488,8 +1488,14 @@ class Settings {
// Check if any layer has app_installations config (org, suborg, or repo)
const hasOrgConfig = appInstallationsConfig && Array.isArray(appInstallationsConfig) && appInstallationsConfig.length > 0
const hasChangedConfigs = (changedSubOrgs && changedSubOrgs.length > 0) || (changedRepos && changedRepos.length > 0)
+ const hasPrecomputedChanges = appChanges && appChanges.length > 0
- if (!hasOrgConfig && !hasChangedConfigs && (!appChanges || appChanges.length === 0)) {
+ // In full-sync mode (no delta inputs) app_installations may be defined only
+ // at the repo or suborg layer, with nothing at the org level. Detect those
+ // so the plugin still runs when org settings.yml has no app_installations.
+ const hasLayeredConfig = !hasChangedConfigs && !hasPrecomputedChanges && this._hasLayeredAppInstallations()
+
+ if (!hasOrgConfig && !hasChangedConfigs && !hasPrecomputedChanges && !hasLayeredConfig) {
this.log.debug('No app_installations config found, skipping')
return
}
@@ -1555,6 +1561,40 @@ class Settings {
this.appendToResults(results)
}
+ /**
+ * Detect app_installations defined at the repo or suborg layer (used in
+ * full-sync mode where org settings.yml may have no app_installations of its
+ * own but repo/suborg configs still declare apps to manage).
+ * @private
+ */
+ _hasLayeredAppInstallations () {
+ const hasInMap = (map) => {
+ if (!map) return false
+ for (const cfg of Object.values(map)) {
+ if (cfg && Array.isArray(cfg.app_installations) && cfg.app_installations.length > 0) return true
+ }
+ return false
+ }
+ return hasInMap(this.repoConfigs) || hasInMap(this.subOrgConfigs)
+ }
+
+ /**
+ * Report a configured `app_installations` app_slug that is not installed on
+ * the org (typically a typo, or an app that has not been installed yet).
+ * Surfaced as an ERROR so the PR check run / sync fails visibly instead of
+ * silently skipping the entry.
+ * @private
+ */
+ _reportUnknownApp (slug, layer) {
+ const where = layer ? ` (${layer})` : ''
+ const msg = `app_installations: app '${slug}'${where} is not installed on org '${this.repo.owner}'. Check the app_slug for typos and ensure the GitHub App is installed. Skipping this app.`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ if (this.nop) {
+ this.appendToResults([new NopCommand('app_installations', this.repo, null, msg, 'ERROR', { name: slug, type: 'app' })])
+ }
+ }
+
/**
* Build delta-based app changes from changed suborg/repo config files.
* Loads both current and previous (baseRef) versions of each changed config,
@@ -1603,7 +1643,10 @@ class Settings {
if (orgAllApps.has(slug)) return null
if (!appChangeMap.has(slug)) {
const installationId = installationMap.get(slug)
- if (!installationId) return null
+ if (!installationId) {
+ this._reportUnknownApp(slug, 'suborg/repo')
+ return null
+ }
appChangeMap.set(slug, {
app_slug: slug,
installation_id: installationId,
@@ -1752,6 +1795,9 @@ class Settings {
const AppOctokitClient = require('./appOctokitClient')
const desiredState = {}
const repoSelector = new RepoSelector(this.github, this.repo.owner, this.log)
+ // Org-level app_installations may be absent entirely (apps declared only at
+ // the repo/suborg layer); normalise so the org loop below is safe.
+ if (!Array.isArray(orgAppInstallations)) orgAppInstallations = []
// Get all org installations to map app_slug → installation_id
let orgInstallations = []
@@ -1781,7 +1827,7 @@ class Settings {
const installationId = installationMap.get(slug)
if (!installationId) {
- this.log.debug(`App '${slug}' not found in org installations, skipping`)
+ this._reportUnknownApp(slug, 'org settings.yml')
continue
}
@@ -1811,7 +1857,7 @@ class Settings {
if (!slug) continue
if (!desiredState[slug]) {
const installationId = installationMap.get(slug)
- if (!installationId) continue
+ if (!installationId) { this._reportUnknownApp(slug, 'suborg'); continue }
desiredState[slug] = { installation_id: installationId, repos: new Set() }
}
// Org "all" takes precedence — don't add specific repos
@@ -1834,7 +1880,7 @@ class Settings {
if (!slug) continue
if (!desiredState[slug]) {
const installationId = installationMap.get(slug)
- if (!installationId) continue
+ if (!installationId) { this._reportUnknownApp(slug, 'repo'); continue }
desiredState[slug] = { installation_id: installationId, repos: new Set() }
}
if (desiredState[slug].repos === 'all') continue
From 1b6353126169277415d052c3e1eef184356c2085 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 17:44:14 -0400
Subject: [PATCH 53/67] syncSelectedRepos() repoConfigs often contains only the
*last* processed repo (loadConfigs(repo) loads a single repo override), so
missing entries
---
lib/settings.js | 36 ++++++++++++++++++++++++++++++++----
1 file changed, 32 insertions(+), 4 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index 66ad30859..cb67dfc24 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1674,7 +1674,21 @@ class Settings {
// Process changed suborg configs
for (const suborg of changedSubOrgs) {
- const currentConfig = this.subOrgConfigs && this.subOrgConfigs[suborg.repo]
+ // Resolve the CURRENT suborg config. this.subOrgConfigs is unreliable in
+ // the delta path: getSubOrgConfigs keys it by the suborg file name (with
+ // extension) and by each targeted repo — never by the bare suborg name
+ // (suborg.repo) — and syncSelectedRepos filters it down to a single
+ // suborg's targeted repos. When the lookup misses, load the config
+ // authoritatively from this.ref (mirroring the previous-version load) so
+ // apps are not misclassified as removed and emit incorrect unselections.
+ let currentConfig = this.subOrgConfigs && this.subOrgConfigs[suborg.repo]
+ if (!currentConfig && suborg.path) {
+ try {
+ currentConfig = await this.loadYamlFromRef(suborg.path, this.ref)
+ } catch (e) {
+ this.log.debug(`Could not load current suborg config for '${suborg.repo || suborg.name}': ${e.message}`)
+ }
+ }
const currentApps = (currentConfig && currentConfig.app_installations) || []
const currentAppSlugs = new Set(currentApps.map(a => a.app_slug).filter(Boolean))
@@ -1686,7 +1700,7 @@ class Settings {
previousConfig = await this.loadYamlFromRef(suborg.path, baseRef)
previousApps = (previousConfig && previousConfig.app_installations) || []
} catch (e) {
- this.log.debug(`Could not load previous suborg config for '${suborg.repo}': ${e.message}`)
+ this.log.debug(`Could not load previous suborg config for '${suborg.repo || suborg.name}': ${e.message}`)
}
}
const previousAppSlugs = new Set(previousApps.map(a => a.app_slug).filter(Boolean))
@@ -1732,15 +1746,29 @@ class Settings {
// Process changed repo configs
for (const repo of changedRepos) {
- const repoConfig = this.repoConfigs &&
+ const repoFilePath = path.posix.join(CONFIG_PATH, 'repos', `${repo.repo}.yml`)
+
+ // Resolve the CURRENT repo config. During syncSelectedRepos this.repoConfigs
+ // is loaded one repo at a time and typically retains only the last
+ // processed repo, so it cannot be relied on for every changed repo. When
+ // the entry is missing, load it authoritatively from this.ref — otherwise
+ // other changed repos would look empty and be treated as "app removed",
+ // emitting incorrect unselections.
+ let repoConfig = this.repoConfigs &&
(this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`])
+ if (!repoConfig) {
+ try {
+ repoConfig = await this.loadYamlFromRef(repoFilePath, this.ref)
+ } catch (e) {
+ this.log.debug(`Could not load current repo config for '${repo.repo}': ${e.message}`)
+ }
+ }
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) || []
From 26b5010911da2ac9096355ca6d1298d68ffed071 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 17:48:49 -0400
Subject: [PATCH 54/67] Handle authentication errors by clearing cached
enterprise installation IDs
---
index.js | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/index.js b/index.js
index 02b8a136f..302ed2790 100644
--- a/index.js
+++ b/index.js
@@ -200,8 +200,12 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
// enterprise.
const cachedId = cachedEnterpriseInstallationIds.get(enterpriseSlug)
if (cachedId) {
- const appGithub = await robot.auth(cachedId)
- return { appGithub, installationId: cachedId }
+ try {
+ const appGithub = await robot.auth(cachedId)
+ return { appGithub, installationId: cachedId }
+ } catch (e) {
+ cachedEnterpriseInstallationIds.delete(enterpriseSlug)
+ }
}
// Find the installation targeting this enterprise
From 60c4b7aaad453701c64d6788bfcf1c1129e23212 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 18:06:59 -0400
Subject: [PATCH 55/67] Map and Array mismatch will cause delta-based sync to
skip real add/remove operations.
---
lib/plugins/appInstallations.js | 25 +++++++++++--
.../unit/lib/plugins/appInstallations.test.js | 36 +++++++++++++++++++
2 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index aede50331..752fcffee 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -51,8 +51,8 @@ class AppInstallations {
* {
* app_slug: string,
* installation_id: number,
- * repository_selection: Set | 'all', // repos to add
- * repository_unselection: Set, // repos to remove
+ * repository_selection: Set | Array | 'all', // repos to add
+ * repository_unselection: Set | Array, // repos to remove
* }
* @returns {Promise} NopCommand results (in nop mode) or empty
*/
@@ -233,11 +233,30 @@ class AppInstallations {
*/
async _processAppChange (change) {
const results = []
- const { app_slug, installation_id, repository_selection, repository_unselection } = change
+ const { app_slug, installation_id } = change
+
+ // Normalise selection/unselection to Sets. Delta changes computed by
+ // Settings._buildAppChangesFromDelta arrive as arrays, while direct callers
+ // and unit tests pass Sets — accept both. 'all' is a sentinel for the
+ // whole-org toggle and is handled separately below.
+ const repository_selection = change.repository_selection === 'all'
+ ? 'all'
+ : (change.repository_selection instanceof Set
+ ? change.repository_selection
+ : new Set(change.repository_selection || []))
+ const repository_unselection = change.repository_unselection instanceof Set
+ ? change.repository_unselection
+ : new Set(change.repository_unselection || [])
if (!this.enterpriseClient) {
const msg = 'Cannot sync app installations: enterprise client not configured. Ensure safe-settings is installed on the enterprise.'
this.log.error(msg)
+ this.errors.push({
+ owner: this.repo.owner,
+ repo: this.repo.repo,
+ msg,
+ plugin: 'app_installations'
+ })
if (this.nop) {
results.push(new NopCommand('app_installations', this.repo, null, msg, 'ERROR'))
}
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
index a69c23eb8..20ce665e4 100644
--- a/test/unit/lib/plugins/appInstallations.test.js
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -127,6 +127,42 @@ describe('AppInstallations', () => {
// config and added by another ends up present.
expect(callOrder).toEqual(['remove', 'add'])
})
+
+ it('accepts array-based selection/unselection (Settings._buildAppChangesFromDelta output shape)', async () => {
+ // Settings._buildAppChangesFromDelta returns arrays (not Sets). The
+ // plugin must treat them the same as Sets, otherwise real add/remove
+ // operations are silently skipped.
+ const routes = []
+ appGithub.request.mockImplementation((route) => {
+ routes.push(route)
+ return Promise.resolve({ data: {} })
+ })
+
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: ['repo-a'],
+ repository_unselection: ['repo-b']
+ }])
+
+ expect(routes.some(r => r.includes('/repositories/add'))).toBe(true)
+ expect(routes.some(r => r.includes('/repositories/remove'))).toBe(true)
+ })
+
+ it('generates NopCommand for array-based selection/unselection in nop mode', async () => {
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: ['repo-a', 'repo-b'],
+ repository_unselection: ['repo-c']
+ }])
+
+ expect(result).toHaveLength(1)
+ expect(result[0].action.additions).toEqual(['repo-a', 'repo-b'])
+ expect(result[0].action.deletions).toEqual(['repo-c'])
+ })
})
describe('syncFull', () => {
From 19d911b24964c50a7be87ff81a665b3eab0c15d5 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 18:09:44 -0400
Subject: [PATCH 56/67] Fix error that makes the app_installations phase
silently no-op (and may mark the run successful) even though enterprise
permissions are missing/mis-scoped.
---
lib/settings.js | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/lib/settings.js b/lib/settings.js
index cb67dfc24..0ca0b4280 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1834,7 +1834,10 @@ class Settings {
try {
orgInstallations = await enterpriseClient.listOrgInstallations(this.repo.owner)
} catch (e) {
- this.log.error(`Failed to list org installations: ${e.message}`)
+ const msg = `Failed to list org installations: ${e.message}`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ if (this.nop) this.appendToResults([new NopCommand('app_installations', this.repo, null, msg, 'ERROR')])
return desiredState
}
}
From 97e2b8130b4eaea1040f4b7d9d05ecb4fd2e8d4d Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 18:15:32 -0400
Subject: [PATCH 57/67] protective to change to lowercase
---
lib/settings.js | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/lib/settings.js b/lib/settings.js
index 0ca0b4280..c9af99a5e 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1617,7 +1617,10 @@ class Settings {
installationMap.set(inst.app_slug, inst.id)
}
} catch (e) {
- this.log.error(`Failed to list org installations for delta: ${e.message}`)
+ const msg = `Failed to list org installations for delta: ${e.message}`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ if (this.nop) this.appendToResults([new NopCommand('app_installations', this.repo, null, msg, 'ERROR')])
return []
}
}
From 4e8240c610aafe2fc21b85e1706fea55507e1c3e Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 7 Jul 2026 18:20:37 -0400
Subject: [PATCH 58/67] cleaned up test
---
test/unit/lib/settings.test.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 32fe12471..93de139f9 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -1694,12 +1694,12 @@ repository:
expect(result[0].repository_unselection.sort()).toEqual(['repo-a'])
})
- it('skips apps configured as repository_selection: all at org level', async () => {
+ it('skips apps configured at the org level (org entry implies all repos)', async () => {
jest.spyOn(RepoSelector.prototype, 'resolve').mockResolvedValue(new Set(['repo-a']))
settings.config = {
...settings.config,
- app_installations: [{ app_slug: 'my-app', repository_selection: 'all' }]
+ app_installations: [{ app_slug: 'my-app' }]
}
settings.subOrgConfigs = {
frontend: { suborgrepos: ['repo-a'], app_installations: [{ app_slug: 'my-app' }] }
From 57ce4bc15ce878f0dbe1884b8b57774af8ba4f6e Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 8 Jul 2026 09:23:47 -0400
Subject: [PATCH 59/67] Fix edge case where deleting and adding may return 422
for full sync
---
docs/adr/0001-app-installation-plugin.md | 15 ++++-
lib/plugins/appInstallations.js | 40 +++++++++++--
.../unit/lib/plugins/appInstallations.test.js | 59 +++++++++++++++++++
3 files changed, 105 insertions(+), 9 deletions(-)
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index 42664f970..8859886db 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -158,9 +158,18 @@ To fix this without a disruptive rename of the repo-centric reporting pipeline,
names, so the plugin no longer resolves names → IDs or enumerates all repos
for the "all" case (it uses the native toggle instead).
-6. **Unselection before selection.** In both delta and full sync, removals are
- applied before additions, so a repo removed by one config layer and added by
- another ends up **present** (net-correct even with transient churn).
+6. **Unselection before selection (delta); selection before unselection (full
+ sync).** In **delta** mode the add/remove sets can overlap across config
+ layers, so removals are applied first and additions second — a repo removed
+ by one layer and added by another ends up **present** (net-correct even with
+ transient churn). In **full sync** the add/remove sets are disjoint by
+ construction (`toAdd = desired − live`, `toRemove = live − desired`), so
+ ordering does not change the final set; there, additions are applied
+ **before** removals so a "swap" (e.g. live `{A}` → desired `{B}`) never drops
+ the `selected` installation to zero repositories, which the Enterprise API
+ rejects with `422`. Reconciling a `selected` installation to an **empty**
+ desired set is impossible (it must keep ≥1 repo); this is surfaced as a
+ descriptive error rather than attempted.
7. **Churn skip.** In delta mode, if an app's targeting is unchanged between the
previous and current versions of a file, it is skipped entirely to avoid
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index 752fcffee..838bfe963 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -203,6 +203,20 @@ class AppInstallations {
return results
}
+ // A 'selected' installation must retain at least one repository. When the
+ // resolved desired set is empty we would have to remove every repo, which
+ // the Enterprise API rejects (422). There is no valid reconciliation to
+ // zero repos — surface a descriptive error instead of attempting it.
+ if (desiredNames.size === 0) {
+ const msg = `App '${appSlug}': cannot reconcile a 'selected' installation to zero repositories (the resolved repo set is empty). Add at least one repository to the app's configuration, set the app to all repos at the org level, or remove the app_installations entry.`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, msg, 'ERROR', { name: appSlug, type: 'app' }))
+ }
+ return results
+ }
+
if (this.nop) {
results.push(new NopCommand('app_installations', this.repo, null, {
msg: `App '${appSlug}' installation repos`,
@@ -213,16 +227,30 @@ class AppInstallations {
return results
}
- // Process removals first, then additions, so a repo that should be both
- // removed (by one config) and added (by another) ends up present.
- if (toRemove.length > 0) {
- await this.enterpriseClient.removeReposFromInstallation(this.org, installation_id, toRemove)
- this.log.debug(`App '${appSlug}': removed ${toRemove.length} repos`)
- }
+ // Apply additions BEFORE removals. In a swap (e.g. live={A}, desired={B})
+ // removing first would momentarily drop the installation to zero repos,
+ // which the Enterprise API rejects with 422 ('selected' installations must
+ // keep at least one repo). Adding first guarantees the installation never
+ // passes through an empty state. In full sync toAdd/toRemove are disjoint,
+ // so ordering does not change the final repo set.
if (toAdd.length > 0) {
await this.enterpriseClient.addReposToInstallation(this.org, installation_id, toAdd)
this.log.debug(`App '${appSlug}': added ${toAdd.length} repos`)
}
+ if (toRemove.length > 0) {
+ try {
+ await this.enterpriseClient.removeReposFromInstallation(this.org, installation_id, toRemove)
+ this.log.debug(`App '${appSlug}': removed ${toRemove.length} repos`)
+ } catch (e) {
+ if (e && e.status === 422) {
+ const msg = `App '${appSlug}': removing ${toRemove.length} repo(s) was rejected by the Enterprise API (422); a 'selected' installation must retain at least one repository.`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ return results
+ }
+ throw e
+ }
+ }
return results
}
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
index 20ce665e4..f2650eaed 100644
--- a/test/unit/lib/plugins/appInstallations.test.js
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -282,5 +282,64 @@ describe('AppInstallations', () => {
expect(result).toEqual([])
expect(appGithub.request).not.toHaveBeenCalled()
})
+
+ it('applies additions before removals to avoid a transient empty selection (422-safe swap)', async () => {
+ // live = {repo-a}; desired = {repo-b} → swap. Removing first could drop the
+ // installation to zero repos (422); additions must be applied first.
+ appGithub.paginate.mockResolvedValue([{ name: 'repo-a', id: 10 }])
+ const routes = []
+ appGithub.request.mockImplementation((route) => {
+ routes.push(route)
+ return Promise.resolve({ data: {} })
+ })
+
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(['repo-b']), current_selection: 'selected' }
+ })
+
+ const addIdx = routes.findIndex(r => r.includes('/repositories/add'))
+ const removeIdx = routes.findIndex(r => r.includes('/repositories/remove'))
+ expect(addIdx).toBeGreaterThanOrEqual(0)
+ expect(removeIdx).toBeGreaterThanOrEqual(0)
+ expect(addIdx).toBeLessThan(removeIdx)
+ })
+
+ it("errors (without mutating) when the desired repo set is empty for a 'selected' installation", async () => {
+ appGithub.paginate.mockResolvedValue([{ name: 'repo-a', id: 10 }])
+
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(), current_selection: 'selected' }
+ })
+
+ expect(result).toHaveLength(1)
+ expect(result[0].type).toBe('ERROR')
+ expect(errors.some(e => /zero repositories/i.test(e.msg))).toBe(true)
+ expect(appGithub.request).not.toHaveBeenCalled()
+ })
+
+ it('records a descriptive error when removal is rejected with 422', async () => {
+ appGithub.paginate.mockResolvedValue([
+ { name: 'repo-a', id: 10 },
+ { name: 'repo-b', id: 20 }
+ ])
+ appGithub.request.mockImplementation((route) => {
+ if (route.includes('/repositories/remove')) {
+ return Promise.reject(Object.assign(new Error('Unprocessable'), { status: 422 }))
+ }
+ return Promise.resolve({ data: {} })
+ })
+
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ // desired = {repo-a}; live = {repo-a, repo-b} → toRemove = [repo-b]
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(['repo-a']), current_selection: 'selected' }
+ })
+
+ // The 422 is caught and recorded, not thrown out of syncFull.
+ expect(Array.isArray(result)).toBe(true)
+ expect(errors.some(e => /422/.test(e.msg))).toBe(true)
+ })
})
})
From 61f31416cc31bc69ecc6eb644ba3fd1c635eba41 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 8 Jul 2026 09:30:44 -0400
Subject: [PATCH 60/67] Also took add-then-delete approach for delta-sync to
avoid 422
---
docs/adr/0001-app-installation-plugin.md | 39 +++++++++++--------
lib/plugins/appInstallations.js | 31 ++++++++++++---
.../unit/lib/plugins/appInstallations.test.js | 31 +++++++++++++--
3 files changed, 75 insertions(+), 26 deletions(-)
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index 8859886db..d3d42294e 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -158,18 +158,20 @@ To fix this without a disruptive rename of the repo-centric reporting pipeline,
names, so the plugin no longer resolves names → IDs or enumerates all repos
for the "all" case (it uses the native toggle instead).
-6. **Unselection before selection (delta); selection before unselection (full
- sync).** In **delta** mode the add/remove sets can overlap across config
- layers, so removals are applied first and additions second — a repo removed
- by one layer and added by another ends up **present** (net-correct even with
- transient churn). In **full sync** the add/remove sets are disjoint by
- construction (`toAdd = desired − live`, `toRemove = live − desired`), so
- ordering does not change the final set; there, additions are applied
- **before** removals so a "swap" (e.g. live `{A}` → desired `{B}`) never drops
- the `selected` installation to zero repositories, which the Enterprise API
- rejects with `422`. Reconciling a `selected` installation to an **empty**
- desired set is impossible (it must keep ≥1 repo); this is surfaced as a
- descriptive error rather than attempted.
+6. **Additions before removals (both delta and full sync).** The add/remove
+ sets are always disjoint before they are applied: in **delta** mode
+ `_buildAppChangesFromDelta` drops any repo appearing in both selection and
+ unselection (selection wins), and in **full sync** they are disjoint by
+ construction (`toAdd = desired − live`, `toRemove = live − desired`). Because
+ the sets are disjoint, ordering does not change the final repo set — so
+ additions are applied **first** in both paths. Adding first prevents a
+ "swap" (e.g. live `{A}` → desired `{B}`) from momentarily dropping a
+ `selected` installation to zero repositories, which the Enterprise API
+ rejects with `422`. Full sync knows the complete desired set, so it also
+ detects an **empty** desired set up-front and errors rather than attempting
+ an impossible reconcile-to-zero. Delta does not fetch live state, so it
+ cannot detect that case proactively; it catches the `422` on removal, emits a
+ descriptive error, and defers the correct end state to the next full sync.
7. **Churn skip.** In delta mode, if an app's targeting is unchanged between the
previous and current versions of a file, it is skipped entirely to avoid
@@ -178,8 +180,8 @@ To fix this without a disruptive rename of the repo-centric reporting pipeline,
8. **Full-sync `current_selection` awareness.** Full sync reads each
installation's live `repository_selection` and chooses the minimal action:
skip when already correct; toggle `all` ↔ `selected`; or diff names and
- remove-then-add when already `selected`. In `additive` mode it never narrows
- an `all` installation.
+ add-then-remove when already `selected` (see decision #6). In `additive` mode
+ it never narrows an `all` installation.
9. **`disable_plugins` / `additive_plugins` support.** `app_installations`
participates in the same gating: it can be disabled at any layer, and in
@@ -245,9 +247,12 @@ To fix this without a disruptive rename of the repo-centric reporting pipeline,
`installation.repositories_added/removed` handler was intentionally **removed**
because it could not detect managed-app drift; only `installation_target` is
retained. Drift on managed apps is reconciled on the next cron full sync.
-- **Multi-suborg overlap can briefly churn** in delta mode (a repo may be
- removed then re-added within a run). The unselection-before-selection ordering
- guarantees the net end state is correct.
+- **Multi-suborg overlap** in delta mode is handled by de-duplicating the
+ selection/unselection sets (selection wins) and applying additions before
+ removals, so the net end state is correct and a `selected` installation is
+ never momentarily emptied. A removal that would still drop the installation to
+ zero repos (which delta cannot detect without a live-state fetch) is caught as
+ a `422`, reported, and reconciled by the next full sync.
- Requires an enterprise-level installation with the specific permission; orgs
not on enterprise cannot use the plugin.
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index 838bfe963..e284444a7 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -342,16 +342,37 @@ class AppInstallations {
return results
}
- if (hasUnselections) {
- await this.enterpriseClient.removeReposFromInstallation(this.org, installation_id, [...repository_unselection])
- this.log.debug(`App '${app_slug}': removed ${repository_unselection.size} repos`)
- }
-
+ // Apply additions BEFORE removals. Delta selection/unselection sets are
+ // disjoint (Settings._buildAppChangesFromDelta drops any repo appearing in
+ // both, selection winning), so ordering does not change the final set.
+ // Adding first avoids a transient empty selection during a "swap" (e.g. a
+ // suborg retargeted from repo-A to repo-B where the installation currently
+ // holds only repo-A), which the Enterprise API rejects with 422 ('selected'
+ // installations must keep at least one repo).
if (hasSelections) {
await this.enterpriseClient.addReposToInstallation(this.org, installation_id, [...repository_selection])
this.log.debug(`App '${app_slug}': added ${repository_selection.size} repos`)
}
+ if (hasUnselections) {
+ try {
+ await this.enterpriseClient.removeReposFromInstallation(this.org, installation_id, [...repository_unselection])
+ this.log.debug(`App '${app_slug}': removed ${repository_unselection.size} repos`)
+ } catch (e) {
+ if (e && e.status === 422) {
+ // Delta does not fetch live installation state, so it cannot detect
+ // up-front that a removal would drop the installation to zero repos.
+ // Surface a descriptive error; the scheduled full sync reconciles the
+ // correct end state.
+ const msg = `App '${app_slug}': removing ${repository_unselection.size} repo(s) was rejected by the Enterprise API (422); a 'selected' installation must retain at least one repository. This will be reconciled on the next full sync.`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ return results
+ }
+ throw e
+ }
+ }
+
return results
}
}
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
index f2650eaed..2677921c4 100644
--- a/test/unit/lib/plugins/appInstallations.test.js
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -107,7 +107,7 @@ describe('AppInstallations', () => {
expect(result[0].action.deletions).toBeNull()
})
- it('processes unselections before selections in non-nop mode', async () => {
+ it('processes additions before unselections in non-nop mode (422-safe swap)', async () => {
const callOrder = []
appGithub.request.mockImplementation((route) => {
if (route.includes('/repositories/remove')) callOrder.push('remove')
@@ -123,9 +123,32 @@ describe('AppInstallations', () => {
repository_unselection: new Set(['repo-b'])
}])
- // Removal must be applied before addition so a repo removed by one
- // config and added by another ends up present.
- expect(callOrder).toEqual(['remove', 'add'])
+ // Additions are applied before removals so a "swap" never drops the
+ // installation to zero repos (which the Enterprise API rejects with 422).
+ // Selection/unselection are already disjoint (dedup), so ordering does not
+ // affect the final set.
+ expect(callOrder).toEqual(['add', 'remove'])
+ })
+
+ it('records a descriptive error when a delta removal is rejected with 422', async () => {
+ appGithub.request.mockImplementation((route) => {
+ if (route.includes('/repositories/remove')) {
+ return Promise.reject(Object.assign(new Error('Unprocessable'), { status: 422 }))
+ }
+ return Promise.resolve({ data: {} })
+ })
+
+ const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncDelta([{
+ app_slug: 'copilot',
+ installation_id: 1,
+ repository_selection: new Set(),
+ repository_unselection: new Set(['repo-a'])
+ }])
+
+ // The 422 is caught and recorded, not thrown out of syncDelta.
+ expect(Array.isArray(result)).toBe(true)
+ expect(errors.some(e => /422/.test(e.msg))).toBe(true)
})
it('accepts array-based selection/unselection (Settings._buildAppChangesFromDelta output shape)', async () => {
From 498631c775993e51aa29a57201e5c356267a5821 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 8 Jul 2026 09:33:14 -0400
Subject: [PATCH 61/67] Normalize enterprise slug comparison to lowercase for
consistency
---
index.js | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/index.js b/index.js
index 302ed2790..d3a0908b8 100644
--- a/index.js
+++ b/index.js
@@ -175,7 +175,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
async function findEnterpriseInstallation (enterpriseSlug) {
const installations = await listAllInstallations()
return installations.find(
- i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterpriseSlug
+ i => i.target_type === 'Enterprise' && i.account && `${i.account.slug}`.toLowerCase() === enterpriseSlug.toLowerCase()
) || null
}
@@ -194,6 +194,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
*/
async function getEnterpriseAppClient (enterpriseSlug) {
if (!enterpriseSlug) return null
+ // Normalize the slug to lowercase for consistent cache keying
+ enterpriseSlug = enterpriseSlug.toLowerCase()
// Use the cached enterprise installation id for THIS slug if available.
// Keying by slug ensures a cached id is never reused for a different
From 58205b5dc038fa3b9ed92850835e434943e9e706 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 8 Jul 2026 12:12:04 -0400
Subject: [PATCH 62/67] Document app-centric shift and delta/full-sync model in
installation plugin
---
docs/adr/0001-app-installation-plugin.md | 31 +++++++++++++++++++++---
1 file changed, 27 insertions(+), 4 deletions(-)
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index d3d42294e..391d9776d 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -2,10 +2,13 @@
- Status: Accepted
- Date: 2026-07-06
-- Last updated: 2026-07-07 — added reporting subject model, per-repo pipeline
- exclusion, startup verification, non-managed-app safety guarantee, smoke
- tests, and removed the redundant `repository_selection` config attribute
- (org level is implicitly "all").
+- Last updated: 2026-07-08 — documented the repo-centric → app-centric shift and
+ the delta/full-sync model, and made reconciliation 422-safe (additions before
+ removals in both paths, with an explicit error when a `selected` installation
+ would be reduced to zero repositories). Earlier (2026-07-07): reporting subject
+ model, per-repo pipeline exclusion, startup verification, non-managed-app
+ safety guarantee, smoke tests, and removal of the redundant
+ `repository_selection` config attribute (org level is implicitly "all").
- Deciders: safe-settings maintainers
- Related PR: `decyjphr-app-installation-plugin`
@@ -48,6 +51,26 @@ Copilot policies) without another ground-up rewrite.
Add an `app_installations` plugin plus supporting infrastructure, wired into
the existing sync pipeline as a **separate phase**.
+This capability shifts safe-settings from a purely **repo-centric** model toward
+a more general one. Until now, a trigger — a change to `settings.yml`, a
+`suborgs/*.yml`, or a `repos/*.yml` — caused `syncAll` or `syncSelectedRepos` to
+resolve a **collection of repositories** and invoke each plugin against them.
+App installation management inverts part of that flow: a change still resolves a
+collection of repositories, but it **also** resolves a **collection of
+applications**, which are then processed iteratively. For each application the
+plugin computes the set of repositories that should constitute its
+`repository_selection`.
+
+The subtlety is that a single `repos/*.yml` change surfaces only **one**
+repository in the changed set, whereas an app's desired access is the **union of
+every** repository that currently targets it — conceptually `existing + new`.
+Removal is harder still: to know which repositories an app should *lose*, we must
+compare against the repositories derived from the **previous commit** on the
+default branch. We call the process of computing these per-app additions and
+deletions from a config change **delta sync**; a complementary **full sync**
+recomputes each app's complete desired state from scratch to reconcile
+configuration drift. Both are detailed under *Sync model* below.
+
### Configuration shape
```yaml
From ff44c8b05c1c4b8a80538f68ad2186f857862f17 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Wed, 8 Jul 2026 15:07:11 -0400
Subject: [PATCH 63/67] minor doc updates
---
README.md | 2 +-
docs/adr/0001-app-installation-plugin.md | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 877f6c298..4419722c8 100644
--- a/README.md
+++ b/README.md
@@ -738,7 +738,7 @@ still selects them.
file changes. Only the apps affected by the changed file are reconciled: the
previous version of the file is compared with the new one to compute repos to
add (`repository_selection`) and repos to remove (`repository_unselection`).
- Removals are applied before additions, so a repo removed by one config and
+ Additions are applied before removals (422-safe swap), so a repo removed by one config and
added by another ends up present.
- **Full sync** runs on the schedule (cron), on manual sync, and when
`settings.yml` changes. It recomputes the full desired state for every managed
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index 391d9776d..534fe6d5f 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -76,16 +76,16 @@ configuration drift. Both are detailed under *Sync model* below.
```yaml
# settings.yml (org level) — implies "all repos"
app_installations:
- - app_slug: copilot
- - app_slug: dependabot
+ - app_slug: ghas-compliance-decyjphr-emu
+ - app_slug: migrator-destination-dotcom
# suborgs/team-a.yml — repos selected by this suborg's criteria
app_installations:
- - app_slug: copilot
+ - app_slug: migrator-destination-dotcom
# repos/my-repo.yml — this specific repo
app_installations:
- - app_slug: copilot
+ - app_slug: migrator-destination-dotcom
```
### Components
From 95c3ad66ecad0638767b124536ec5415636453a8 Mon Sep 17 00:00:00 2001
From: Vishal Dawange
Date: Thu, 9 Jul 2026 23:31:30 -0500
Subject: [PATCH 64/67] Fix external group handling, mergeDeep crash, and
config validator gap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
teams.js:
- resolveExternalGroupId() now matches external_group names
case-insensitively, preventing false "not found" errors caused by
casing differences vs. the SCIM-synced Azure AD group name.
- syncExternalGroup() no longer treats a missing external group as a
fatal error. Newly-created teams whose SCIM group hasn't finished
provisioning yet now log a warning and emit a WARNING NopCommand
instead of an ERROR, so the PR check surfaces it without failing.
settings.js:
- handleResults() gains full support for the new WARNING NopCommand
type: tracked in stats.warnings, rendered in a dedicated "Warnings"
PR comment section, and excluded from the check_run failure
condition (only ERROR still flips the conclusion to failure).
- childPluginsList(): fixed a validation gap where config entries
declared only at suborg/repo level (no matching org-level baseline
entry by name) were never passed to configvalidators/
overridevalidators. Only entries matching an org-baseline entry were
validated; new entries with no org-level counterpart silently
bypassed validation (e.g. an invalid team `permission` value would
not be rejected). Now validated against an empty baseConfig too.
mergeDeep.js:
- Fixed a crash in compareDeepIfVisited(): merging an addition into a
modification unconditionally called Object.assign, which threw
"Cannot assign to read only property '0' of object '[object
String]'" when both values were primitive strings (e.g. the `name`
identifying attribute added by addIdentifyingAttribute). This
happened whenever an array item had both a modified field (e.g.
team `permission`) and a field missing from the target object (e.g.
`external_group`). The merge now branches by value type — array:
push, object: Object.assign, primitive: direct overwrite.
Tests:
- teams.test.js updated to assert the WARNING (not ERROR) NopCommand
and warning log for the missing-external-group case.
- mergeDeep.test.js: added a regression test reproducing the
permission-change + new-external_group crash scenario.
---
lib/mergeDeep.js | 24 ++++++---
lib/plugins/teams.js | 25 ++++++---
lib/settings.js | 78 +++++++++++++++++++++++++++--
test/unit/lib/mergeDeep.test.js | 43 ++++++++++++++++
test/unit/lib/plugins/teams.test.js | 17 ++++---
5 files changed, 161 insertions(+), 26 deletions(-)
diff --git a/lib/mergeDeep.js b/lib/mergeDeep.js
index 04b8370af..32d5e972c 100644
--- a/lib/mergeDeep.js
+++ b/lib/mergeDeep.js
@@ -329,13 +329,25 @@ class MergeDeep {
if (!this.isEmpty(additions)) {
for (const key in lastAddition) {
- if (!lastModification[key]) {
- lastModification[key] = Array.isArray(lastAddition[key]) ? [] : {}
- }
- if (!Array.isArray(lastAddition[key])) {
- Object.assign(lastModification[key], lastAddition[key])
+ const addedValue = lastAddition[key]
+ if (Array.isArray(addedValue)) {
+ if (!Array.isArray(lastModification[key])) {
+ lastModification[key] = []
+ }
+ lastModification[key].push(...addedValue)
+ } else if (this.isObjectNotArray(addedValue)) {
+ if (!this.isObjectNotArray(lastModification[key])) {
+ lastModification[key] = {}
+ }
+ Object.assign(lastModification[key], addedValue)
} else {
- lastModification[key].push(...lastAddition[key])
+ // Primitive value (e.g. the `name`/`permission` identifying
+ // attribute added by addIdentifyingAttribute). Object.assign
+ // would box a primitive string target and throw
+ // "Cannot assign to read only property '0' of object
+ // '[object String]'" since string indices are read-only, so
+ // just overwrite directly instead of merging.
+ lastModification[key] = addedValue
}
}
additions.length = 0
diff --git a/lib/plugins/teams.js b/lib/plugins/teams.js
index 0d655363a..0df459a2f 100644
--- a/lib/plugins/teams.js
+++ b/lib/plugins/teams.js
@@ -188,7 +188,14 @@ module.exports = class Teams extends Diffable {
)
const byName = new Map()
for (const g of groups) {
- if (g && g.group_name) byName.set(g.group_name, g.group_id)
+ // Keys are lower-cased so lookups are case-insensitive, matching
+ // the comparison used by the SCIM-sync workflow's
+ // wait-for-scim-sync.py (which lower-cases both sides). Without
+ // this, a yaml `external_group` value that differs only in case
+ // from the IdP-provisioned display name would be reported as
+ // "not found" even though the group exists and the CI gate
+ // considers it synced.
+ if (g && g.group_name) byName.set(g.group_name.toLowerCase(), g.group_id)
}
this.log.debug(`Loaded ${byName.size} external group(s) for org ${org}: ${JSON.stringify(Array.from(byName.keys()))}`)
cache.set(org, byName)
@@ -198,7 +205,7 @@ module.exports = class Teams extends Diffable {
cache.set(org, new Map())
}
}
- const id = cache.get(org).get(groupName)
+ const id = cache.get(org).get(groupName.toLowerCase())
if (id === undefined) {
return null
}
@@ -217,13 +224,15 @@ module.exports = class Teams extends Diffable {
const groupId = await this.resolveExternalGroupId(groupName)
if (groupId === null) {
- const msg = `External group '${groupName}' not found for org ${this.repo.owner} (team '${attrs.name}').`
- // logError: feeds the synchronous-run end-of-run errors summary.
- this.logError(msg)
- // For PR dry-run / nop mode, also surface the failure in the check_run
- // output -- which is built from results entries with type === 'ERROR'.
+ const msg = `External group '${groupName}' not found for org ${this.repo.owner} (team '${attrs.name}'). This is expected if the team/group is newly added and has not finished SCIM-provisioning yet.`
+ // Non-fatal: a brand-new team's external group commonly doesn't exist
+ // yet until the Azure AD -> GitHub SCIM cycle completes, so this is
+ // logged as a warning (not logError) to avoid failing the whole sync.
+ this.log.warn(msg)
+ // For PR dry-run / nop mode, surface it as a WARNING (not ERROR) in the
+ // check_run output, so it's visible but doesn't mark the run as failed.
if (this.nop && Array.isArray(nopCommands)) {
- nopCommands.push(new NopCommand(this.constructor.name, this.repo, null, msg, 'ERROR'))
+ nopCommands.push(new NopCommand(this.constructor.name, this.repo, null, msg, 'WARNING'))
}
return
}
diff --git a/lib/settings.js b/lib/settings.js
index bc89e016c..1daafbf9b 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -162,6 +162,52 @@ function filterActionByChangedNames (action, changedNames) {
return filtered
}
+// ---------------------------------------------------------------------------
+// Centralized ruleset bypass_actors helpers
+// ---------------------------------------------------------------------------
+
+// Builds a de-duplication key for a bypass actor entry, keyed on actor_type
+// plus either actor_id or name (whichever is present).
+function bypassActorKey (actor) {
+ const actorType = actor.actor_type || 'unknown'
+ if (actor.actor_id !== undefined && actor.actor_id !== null) return `actor_id:${actorType}:${actor.actor_id}`
+ if (actor.name) return `name:${actorType}:${actor.name}`
+ return JSON.stringify(actor)
+}
+
+// Merges centrally-declared bypass actors into a ruleset's existing
+// bypass_actors, de-duplicating by (actor_type, actor_id|name). Centrally
+// declared actors take precedence over a repo/suborg-declared entry with the
+// same key (e.g. to update bypass_mode). Every entry is shallow-cloned so
+// the Rulesets plugin's in-place name->id resolution (resolveBypassActor)
+// never mutates a shared object across multiple rulesets/repos, which sync
+// concurrently (see Settings#updateAll).
+function mergeBypassActors (existing, centralized) {
+ if (!Array.isArray(centralized) || centralized.length === 0) return existing
+ const merged = new Map()
+ for (const actor of (existing || [])) merged.set(bypassActorKey(actor), actor)
+ for (const actor of centralized) merged.set(bypassActorKey(actor), { ...actor })
+ return Array.from(merged.values())
+}
+
+// Returns a copy of `rulesetEntries` with centrally-declared bypass actors
+// merged into every entry's bypass_actors. This runs before the Rulesets
+// plugin's own current-vs-desired diffing (Diffable.sync), so a ruleset only
+// results in an actual GitHub API call when its resulting bypass_actors
+// differ from what's already applied on GitHub — i.e. new or updated
+// rulesets, or ones missing the centralized actors. Unaffected, unchanged
+// rulesets are left as no-ops by the normal diff, with no special-casing
+// needed here.
+function applyCentralizedBypassActors (rulesetEntries, centralizedBypassActors) {
+ if (!Array.isArray(rulesetEntries) || !Array.isArray(centralizedBypassActors) || centralizedBypassActors.length === 0) {
+ return rulesetEntries
+ }
+ return rulesetEntries.map(ruleset => {
+ if (!ruleset || typeof ruleset !== 'object') return ruleset
+ return { ...ruleset, bypass_actors: mergeBypassActors(ruleset.bypass_actors, centralizedBypassActors) }
+ })
+}
+
// ---------------------------------------------------------------------------
// NOP change-rendering helpers (collapsible, field-level diff summaries)
// ---------------------------------------------------------------------------
@@ -1008,7 +1054,7 @@ class Settings {
if (this.baseConfig) {
this.log.debug('Filtering NOP results using base config comparison')
this.results = this.results.filter(res => {
- if (!res || res.type === 'ERROR') return true
+ if (!res || res.type === 'ERROR' || res.type === 'WARNING') return true
if (res.type === 'INFO' && res.action?.msg && res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null) {
return true
@@ -1059,6 +1105,10 @@ class Settings {
reposProcessed: {},
changes: {},
errors: {},
+ // Non-fatal entries (type === 'WARNING'), e.g. an external_group that
+ // doesn't exist yet because SCIM provisioning hasn't completed. Keyed
+ // by repo. Unlike errors, these do not flip the check_run conclusion.
+ warnings: {},
// Informational entries (type === 'INFO', all-null diff fields), e.g.
// disable_plugins skip messages. Keyed by repo.
infos: {}
@@ -1075,6 +1125,14 @@ class Settings {
? (res.action.msg || res.action.message)
: `${res.action}`
stats.errors[res.repo].push({ msg })
+ } else if (res.type === 'WARNING') {
+ if (!stats.warnings[res.repo]) {
+ stats.warnings[res.repo] = []
+ }
+ const msg = res.action && (res.action.msg || res.action.message)
+ ? (res.action.msg || res.action.message)
+ : `${res.action}`
+ stats.warnings[res.repo].push({ msg })
} else if (res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null) {
// No diff data — informational message (e.g. disable_plugins skip).
if (res.action?.msg) {
@@ -1120,6 +1178,15 @@ class Settings {
).join('\n\n')
}\n\n `
+ const warningRepos = Object.keys(stats.warnings)
+ const warningSection = warningRepos.length === 0
+ ? ''
+ : `### Warnings\n\n:warning: Warnings — ${warningRepos.length} ${pluralize(warningRepos.length, 'repo', 'repos')} affected \n\n${
+ warningRepos.map(repo =>
+ `**${repo}**:\n${stats.warnings[repo].map(w => `* ${w.msg}`).join('\n')}`
+ ).join('\n\n')
+ }\n\n `
+
// Preserve disable_plugins informational messages in the PR comment.
const infoRepos = Object.keys(stats.infos)
const infoSection = infoRepos.length === 0
@@ -1130,7 +1197,7 @@ class Settings {
).join('\n\n')
}\n\n `
- const trailingSections = infoSection ? [errorSection, infoSection] : [errorSection]
+ const trailingSections = [errorSection, warningSection, infoSection].filter(Boolean)
const bodySections = stats.changeSections.length === 0
? ['_No changes to apply._', ...trailingSections]
: [...pluginSectionList, ...trailingSections]
@@ -1370,7 +1437,7 @@ class Settings {
const stripMap = this.computeStripMap()
const additiveSet = this.normalizeAdditivePlugins()
- const rulesetsConfig = this.config.rulesets
+ const rulesetsConfig = applyCentralizedBypassActors(this.config.rulesets, this.config.centralized_ruleset_bypass_actors)
if (rulesetsConfig) {
if (this.isPluginDisabledAnywhere(stripMap, 'rulesets')) {
this.log.debug("disable_plugins: skipping org-level 'rulesets' plugin")
@@ -1795,9 +1862,12 @@ class Settings {
if (section in Settings.PLUGINS) {
this.log.debug(`Found section ${section} in the config. Creating plugin...`)
const Plugin = Settings.PLUGINS[section]
+ const pluginConfig = section === 'rulesets'
+ ? applyCentralizedBypassActors(config, this.config.centralized_ruleset_bypass_actors)
+ : config
// Include sectionName as 3rd element so callers can thread the
// additive_plugins flag without re-deriving the plugin key.
- childPlugins.push([Plugin, config, section])
+ childPlugins.push([Plugin, pluginConfig, section])
}
}
}
diff --git a/test/unit/lib/mergeDeep.test.js b/test/unit/lib/mergeDeep.test.js
index 0d6506781..497dcc964 100644
--- a/test/unit/lib/mergeDeep.test.js
+++ b/test/unit/lib/mergeDeep.test.js
@@ -2027,4 +2027,47 @@ branches:
expect(same.additions).toEqual({})
expect(same.modifications).toEqual({})
})
+
+ // Regression test for: TypeError: Cannot assign to read only property '0' of
+ // object '[object String]'. This was thrown by compareDeepIfVisited when a
+ // team entry had BOTH a changed primitive field (e.g. `permission`, which
+ // becomes a modification) AND a field the target lacks entirely (e.g.
+ // `external_group`, which becomes an addition). addIdentifyingAttribute adds
+ // the `name` identifying value as a plain string to both the addition and
+ // the modification containers, and the old merge logic then tried to
+ // Object.assign(modificationNameString, additionNameString) -- boxing a
+ // primitive string as the assignment target throws because string indices
+ // are read-only.
+ it('CompareDeep does not throw when a team has both a changed field and a new external_group field', () => {
+ const target = [
+ { id: 1, name: 'Azure-Security-GHEC-Runners-Developers', slug: 'azure-security-ghec-runners-developers', permission: 'push', privacy: 'closed' }
+ ]
+ const source = [
+ { name: 'Azure-Security-GHEC-Runners-Developers', permission: 'admin', external_group: 'Azure-Security-GHEC-Runners-Developers', privacy: 'closed' }
+ ]
+
+ const ignorableFields = ['id', 'node_id', 'url']
+ const mockReturnGitHubContext = jest.fn().mockReturnValue({
+ request: () => {}
+ })
+ const mergeDeep = new MergeDeep(
+ log,
+ mockReturnGitHubContext,
+ ignorableFields
+ )
+
+ let merged
+ expect(() => {
+ merged = mergeDeep.compareDeep(target, source)
+ }).not.toThrow()
+
+ expect(merged.hasChanges).toBe(true)
+ expect(merged.modifications).toEqual([
+ {
+ permission: 'admin',
+ name: 'Azure-Security-GHEC-Runners-Developers',
+ external_group: 'Azure-Security-GHEC-Runners-Developers'
+ }
+ ])
+ })
})
diff --git a/test/unit/lib/plugins/teams.test.js b/test/unit/lib/plugins/teams.test.js
index a521e0ce3..8879611c3 100644
--- a/test/unit/lib/plugins/teams.test.js
+++ b/test/unit/lib/plugins/teams.test.js
@@ -15,7 +15,7 @@ describe('Teams', () => {
const org = 'bkeepers'
function configure (config) {
- const log = { debug: jest.fn(), error: console.error }
+ const log = { debug: jest.fn(), error: console.error, warn: console.warn }
const errors = []
return new Teams(undefined, github, { owner: 'bkeepers', repo: 'test' }, config, log, errors)
}
@@ -192,7 +192,7 @@ describe('Teams', () => {
)
})
- it('logs an error and skips when the external group name is not found', async () => {
+ it('logs a warning (not an error) and skips when the external group name is not found', async () => {
const plugin = configure([
{ name: unchangedTeamName, permission: 'push', external_group: 'Nonexistent Group' }
])
@@ -203,12 +203,12 @@ describe('Teams', () => {
'PATCH /orgs/{org}/teams/{team_slug}/external-groups',
expect.anything()
)
- // logError pushes onto the errors array
- expect(plugin.errors.some(e => /Nonexistent Group/.test(JSON.stringify(e)))).toBe(true)
+ // Non-fatal: should not push onto the errors array
+ expect(plugin.errors.some(e => /Nonexistent Group/.test(JSON.stringify(e)))).toBe(false)
})
- it('in nop mode, emits an ERROR NopCommand when the external group is not found (so it appears in the PR check_run)', async () => {
- const log = { debug: jest.fn(), error: console.error }
+ it('in nop mode, emits a WARNING NopCommand when the external group is not found (so it appears in the PR check_run without failing it)', async () => {
+ const log = { debug: jest.fn(), error: console.error, warn: console.warn }
const errors = []
const Teams = require('../../../../lib/plugins/teams')
const plugin = new Teams(true, github, { owner: org, repo: 'test' }, [
@@ -218,8 +218,9 @@ describe('Teams', () => {
const result = await plugin.sync()
expect(Array.isArray(result)).toBe(true)
- const errorCmd = result.find(c => c && c.type === 'ERROR' && /Nonexistent Group/.test(JSON.stringify(c)))
- expect(errorCmd).toBeDefined()
+ const warningCmd = result.find(c => c && c.type === 'WARNING' && /Nonexistent Group/.test(JSON.stringify(c)))
+ expect(warningCmd).toBeDefined()
+ expect(result.some(c => c && c.type === 'ERROR')).toBe(false)
expect(github.request).not.toHaveBeenCalledWith(
'PATCH /orgs/{org}/teams/{team_slug}/external-groups',
expect.anything()
From b9ba636be89a4755aee43979e91cb6bdb9303da8 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 10 Jul 2026 09:49:17 -0400
Subject: [PATCH 65/67] Correct empty desired set handling and document the
decisions
---
docs/adr/0001-app-installation-plugin.md | 28 +++++++++++++++----
lib/plugins/appInstallations.js | 13 +++++++--
.../unit/lib/plugins/appInstallations.test.js | 25 +++++++++++++++++
3 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/docs/adr/0001-app-installation-plugin.md b/docs/adr/0001-app-installation-plugin.md
index 534fe6d5f..5a4ec05a7 100644
--- a/docs/adr/0001-app-installation-plugin.md
+++ b/docs/adr/0001-app-installation-plugin.md
@@ -190,11 +190,29 @@ To fix this without a disruptive rename of the repo-centric reporting pipeline,
additions are applied **first** in both paths. Adding first prevents a
"swap" (e.g. live `{A}` → desired `{B}`) from momentarily dropping a
`selected` installation to zero repositories, which the Enterprise API
- rejects with `422`. Full sync knows the complete desired set, so it also
- detects an **empty** desired set up-front and errors rather than attempting
- an impossible reconcile-to-zero. Delta does not fetch live state, so it
- cannot detect that case proactively; it catches the `422` on removal, emits a
- descriptive error, and defers the correct end state to the next full sync.
+ rejects with `422`.
+
+ A `selected` installation must retain at least one repository, so a change
+ that would drive one to **zero** is surfaced as an error rather than
+ attempted — but *only* when it would actually strip access while
+ safe-settings is managing it. Concretely, an empty desired/required repo set
+ is **not** universally an error:
+ - **Full sync, non-additive, installation would lose all repos** (currently
+ `all` and asked to narrow to none, or currently `selected` with live repos
+ and desired resolves to none) → **error**; the installation is left
+ unchanged. Full sync knows the complete desired set, so it detects this
+ up-front.
+ - **Additive mode** → never an error: additive never narrows or removes, so
+ an empty desired set simply adds nothing.
+ - **Nothing to reconcile** (installation already has no relevant repos) →
+ no-op, no error.
+ - **Org-level `all`** → not applicable; the app is toggled to `all` and the
+ empty case never arises.
+ - **Delta mode** → an empty incremental change is a no-op. Delta does not
+ fetch live state, so it cannot know up-front that a removal would empty the
+ installation; it instead catches the `422` on removal, emits a descriptive
+ error, and defers the correct end state to the next full sync.
+
7. **Churn skip.** In delta mode, if an app's targeting is unchanged between the
previous and current versions of a file, it is skipped entirely to avoid
diff --git a/lib/plugins/appInstallations.js b/lib/plugins/appInstallations.js
index e284444a7..7eea8432a 100644
--- a/lib/plugins/appInstallations.js
+++ b/lib/plugins/appInstallations.js
@@ -171,8 +171,17 @@ class AppInstallations {
return results
}
if (desiredNames.size === 0) {
- // Cannot set 'selected' with an empty list; nothing safe to do here.
- this.log.debug(`App '${appSlug}': desired set is empty, leaving 'all' selection untouched`)
+ // The desired set is empty but the installation is 'all'. We cannot
+ // narrow to zero repositories (the API rejects selecting none), so the
+ // over-broad 'all' access would otherwise be silently preserved.
+ // Surface this as an error so operators notice the (likely)
+ // misconfiguration; the installation is left unchanged.
+ const msg = `App '${appSlug}': desired repo set is empty, so safe-settings cannot narrow repository_selection from 'all' to 'selected' (the API rejects selecting zero repositories). Add at least one repository to the app's configuration, set the app to all repos at the org level, or remove the app_installations entry.`
+ this.log.error(msg)
+ this.errors.push({ owner: this.repo.owner, repo: this.repo.repo, msg, plugin: 'app_installations' })
+ if (this.nop) {
+ results.push(new NopCommand('app_installations', this.repo, null, msg, 'ERROR', { name: appSlug, type: 'app' }))
+ }
return results
}
if (this.nop) {
diff --git a/test/unit/lib/plugins/appInstallations.test.js b/test/unit/lib/plugins/appInstallations.test.js
index 2677921c4..26332ae28 100644
--- a/test/unit/lib/plugins/appInstallations.test.js
+++ b/test/unit/lib/plugins/appInstallations.test.js
@@ -364,5 +364,30 @@ describe('AppInstallations', () => {
expect(Array.isArray(result)).toBe(true)
expect(errors.some(e => /422/.test(e.msg))).toBe(true)
})
+
+ it("errors (without mutating) when narrowing 'all' to an empty desired set", async () => {
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(), current_selection: 'all' }
+ })
+
+ expect(result).toHaveLength(1)
+ expect(result[0].type).toBe('ERROR')
+ expect(errors.some(e => /narrow repository_selection from 'all'/.test(e.msg))).toBe(true)
+ // The over-broad 'all' installation must be left unchanged.
+ expect(appGithub.request).not.toHaveBeenCalled()
+ })
+
+ it("leaves 'all' untouched (no error) for an empty desired set in additive mode", async () => {
+ const plugin = new AppInstallations(true, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
+ plugin.additive = true
+ const result = await plugin.syncFull({
+ copilot: { installation_id: 1, repos: new Set(), current_selection: 'all' }
+ })
+
+ expect(result).toEqual([])
+ expect(errors).toEqual([])
+ expect(appGithub.request).not.toHaveBeenCalled()
+ })
})
})
From dfd9781dd470b752ae2334f4608ca0cfe3e3759d Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:00:07 -0400
Subject: [PATCH 66/67] Update JSDoc for correct method parms
---
lib/settings.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/lib/settings.js b/lib/settings.js
index c9af99a5e..42d4c0f5a 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -1478,8 +1478,10 @@ class Settings {
* @param {object} [options]
* @param {object} [options.appGithub] - App-authenticated Octokit (for enterprise API)
* @param {string} [options.enterpriseSlug] - Enterprise slug from payload
- * @param {Array} [options.changedAppSlugs] - App slugs affected by config changes (delta mode)
- * @param {Array} [options.appChanges] - Pre-computed per-app changes (delta mode)
+ * @param {Array} [options.appChanges] - Pre-computed per-app changes (delta mode); takes precedence over changedSubOrgs/changedRepos
+ * @param {Array} [options.changedSubOrgs] - Changed suborg config descriptors ({ repo|name, path }) to diff (delta mode)
+ * @param {Array} [options.changedRepos] - Changed repo config descriptors ({ owner, repo }) to diff (delta mode)
+ * @param {string} [options.baseRef] - Base git ref used to load the previous config versions when diffing (delta mode)
*/
async syncAppInstallations (options = {}) {
const { appGithub, enterpriseSlug, appChanges, changedSubOrgs, changedRepos, baseRef } = options
From 3ddbe1182810a199434576c880c0de9506ba2ca5 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 10 Jul 2026 11:29:39 -0400
Subject: [PATCH 67/67] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
lib/settings.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/settings.js b/lib/settings.js
index 1daafbf9b..6da75fd35 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -185,7 +185,7 @@ function bypassActorKey (actor) {
function mergeBypassActors (existing, centralized) {
if (!Array.isArray(centralized) || centralized.length === 0) return existing
const merged = new Map()
- for (const actor of (existing || [])) merged.set(bypassActorKey(actor), actor)
+ for (const actor of (existing || [])) merged.set(bypassActorKey(actor), { ...actor })
for (const actor of centralized) merged.set(bypassActorKey(actor), { ...actor })
return Array.from(merged.values())
}